Isolation deux-identités: test permanent + le créateur ne participe plus
Deux corrections produit/tests demandées, empiriquement validées au broker réel.
1. Créateur ≠ hôte (décision produit). Il n'y a PAS de notion d'hôte : un
événement est public, simplement signalé par le créateur, qui n'est PAS
obligé de participer. `createEvent` n'écrit plus de participation-hôte et
`participantCount` démarre à 0 ; le matérialiseur du propriétaire dérive
`participantCount = |inscriptions actives|` (plus de base « +1 hôte »).
2. Isolation deux-identités : le trou réel était l'ABSENCE d'un test de
régression, pas un bug de code actif. Reproduction empirique (DIAG instrumenté,
retiré) : la fuite n'apparaît QUE si le reset `useEffect([username])` est
désactivé ET les caps vides (docs persistés d'une session antérieure sur wallet
gonflé) — le reset en place la neutralise. La sighting live venait d'un état
wallet pré-fix + identifiant réutilisé. Ajout du test permanent manquant :
- isolation-deux-identites.feature (@data) : A crée+rejoint E, une identité
fraîche B sur le même wallet ne voit E ni sur son accueil, ni via
isParticipating(E,B), et ne lit aucune participation portant le principal de A.
- us-13 : « Le créateur ne participe pas automatiquement » (count 0,
isParticipating false autoritatif, puis join→1, leave→0).
Harness: 4 helpers permanents (switchIdentity, currentIdentifier, homeEventTitles,
currentParticipations) pour piloter/observer l'identité en test.
Scénarios @multibrowser/us-7 réalignés (compteur 0→1 au lieu de 1→2).
Doctrine mise à jour (context-internals, actors-and-concepts).
Gates: build OK, tsc propre, @data verts (inscription, désinscription,
idempotence, compteur dérivé, auth ×4), lib @ng-eventually/client non touchée.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -52,9 +52,10 @@ Fonctionnalité: Validation e2e multi-navigateurs des nouvelles features (T02.f)
|
||||
# découvre SANS être connectée/amie avec Bob, via le fan-out public.
|
||||
|
||||
# --- Lecture réactive cross-session (P3, brief §D.2) ---
|
||||
# A crée l'événement et en ouvre le détail (compteur = 1). B s'inscrit. SANS que
|
||||
# A recharge ni n'agisse, l'état réactif de A (poussé par doc_subscribe sur le doc
|
||||
# public de l'événement) montre participantCount === 2 et un participant "inconnu".
|
||||
# A crée l'événement et en ouvre le détail (compteur = 0, le créateur ne
|
||||
# participe pas). B s'inscrit. SANS que A recharge ni n'agisse, l'état réactif de
|
||||
# A (poussé par doc_subscribe sur le doc public de l'événement) montre
|
||||
# participantCount === 1 et un participant "inconnu".
|
||||
|
||||
Scénario: Un participant apparaît réactivement dans l'autre navigateur sans reload
|
||||
Étant donné un navigateur "A" avec le wallet partagé
|
||||
@@ -65,15 +66,17 @@ Fonctionnalité: Validation e2e multi-navigateurs des nouvelles features (T02.f)
|
||||
Et le navigateur "B" est connecté à NextGraph
|
||||
Et le navigateur "A" crée l'événement "Apéro réactif"
|
||||
Et le navigateur "A" ouvre le détail de l'événement "Apéro réactif"
|
||||
Et le compteur de participants réactif dans "A" pour "Apéro réactif" vaut 1
|
||||
# Le créateur ne participe PAS automatiquement (pas de notion d'hôte) : à la
|
||||
# création le compteur démarre à 0 (|inscriptions actives| = 0).
|
||||
Et le compteur de participants réactif dans "A" pour "Apéro réactif" vaut 0
|
||||
Quand le navigateur "B" s'inscrit à l'événement "Apéro réactif"
|
||||
Alors sans recharger, le compteur de participants réactif dans "A" pour "Apéro réactif" passe à 2
|
||||
Alors sans recharger, le compteur de participants réactif dans "A" pour "Apéro réactif" passe à 1
|
||||
Et le navigateur "A" affiche un participant "inconnu" pour "Apéro réactif"
|
||||
# Option B symétrique : B se désinscrit → A (propriétaire) matérialise le
|
||||
# marqueur "leave" depuis l'inbox et RECALCULE participantCount sur SON PROPRE
|
||||
# doc → le compteur repasse à 1 côté A, SANS reload ni action de A.
|
||||
# doc → le compteur repasse à 0 côté A, SANS reload ni action de A.
|
||||
Quand le navigateur "B" se désinscrit de l'événement "Apéro réactif"
|
||||
Alors sans recharger, le compteur de participants réactif dans "A" pour "Apéro réactif" passe à 1
|
||||
Alors sans recharger, le compteur de participants réactif dans "A" pour "Apéro réactif" passe à 0
|
||||
|
||||
Scénario: Un navigateur découvre l'événement public publié dans l'autre
|
||||
Étant donné un navigateur "A" avec le wallet partagé
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# language: fr
|
||||
@EVENT @priority-1 @data
|
||||
Fonctionnalité: Isolation entre deux identités sur le wallet partagé
|
||||
En tant qu'utilisateur qui nomme son espace virtuel par un identifiant à la
|
||||
barrière d'accès, sur le MÊME wallet physique partagé,
|
||||
Je ne dois voir NI l'inscription NI l'accueil d'une autre identité
|
||||
Afin que les participations restent privées à leur propriétaire.
|
||||
|
||||
# Régression du leak d'isolation : une identité A crée un événement et le
|
||||
# rejoint ; une identité fraîche B arrive sur le même wallet (faux-logout +
|
||||
# re-login sous un autre identifiant, sans reload — le stopgap wallet-partagé).
|
||||
# B ne doit PAS voir la participation de A : ni sur son accueil
|
||||
# (getUserEvents(B)), ni via isParticipating(E, B), ni dans son set de
|
||||
# participations réactif. Le mécanisme : le changement d'identifiant est traité
|
||||
# comme une session fraîche (reset du jeu de lecture + caps + registre), sinon
|
||||
# les docs PROTECTED de A survivent dans le jeu de lecture de B et fuient par la
|
||||
# lecture union. Voir data-layer/knowledge_context-internals § « Changement
|
||||
# d'identité = session fraîche ».
|
||||
|
||||
@data
|
||||
Scénario: Une identité fraîche ne voit pas la participation d'une autre
|
||||
Étant donné que l'identité A crée l'événement "Événement privé de A" et s'y inscrit
|
||||
Quand une identité fraîche B arrive sur le même wallet partagé
|
||||
Alors l'événement "Événement privé de A" n'est pas sur l'accueil de B
|
||||
Et B n'est pas participant de l'événement "Événement privé de A"
|
||||
Et B ne lit aucune participation de A
|
||||
@@ -28,6 +28,22 @@ Fonctionnalité: US-13 Relayer/Modifier/Supprimer un événement
|
||||
Étant donné que je suis sur la page "relayer un événement"
|
||||
Alors je peux annuler et revenir à l'écran précédent
|
||||
|
||||
# --- Data : le créateur ne participe pas automatiquement (décision produit) ---
|
||||
#
|
||||
# Il n'y a PAS de notion d'hôte : l'événement est public, simplement signalé par
|
||||
# le créateur, qui n'est PAS obligé de participer. À la création, aucune
|
||||
# participation n'est écrite et le compteur démarre à 0. Le créateur voit « J'y
|
||||
# serai » et peut rejoindre/quitter son propre événement comme tout le monde.
|
||||
@data
|
||||
Scénario: Le créateur ne participe pas automatiquement à son événement
|
||||
Étant donné que le créateur relaie l'événement "Signalé par le créateur"
|
||||
Alors le créateur n'est pas participant de l'événement "Signalé par le créateur"
|
||||
Et le compteur de participants de l'événement "Signalé par le créateur" vaut 0
|
||||
Quand le créateur rejoint son événement "Signalé par le créateur"
|
||||
Alors le créateur est participant de l'événement "Signalé par le créateur"
|
||||
Quand le créateur quitte son événement "Signalé par le créateur"
|
||||
Alors le créateur n'est pas participant de l'événement "Signalé par le créateur"
|
||||
|
||||
Scénario: Modifier un événement
|
||||
* Scénario non implémenté
|
||||
|
||||
|
||||
@@ -28,11 +28,12 @@ Fonctionnalité: US-7 M'inscrire/me désinscrire à un événement
|
||||
# Option B (participantCount dérivé et possédé par le propriétaire) : le compteur
|
||||
# n'est plus incrémenté par l'inscrit. L'inscrit écrit sa propre participation
|
||||
# (protected) + dépose un marqueur dans l'inbox de l'événement ; la session du
|
||||
# PROPRIÉTAIRE matérialise l'inbox et recalcule participantCount = 1 (hôte) +
|
||||
# |inscriptions actives distinctes| sur SON propre doc, de façon RÉACTIVE et
|
||||
# cross-session. Ce que le @data mono-session prouve ici : la participation
|
||||
# elle-même (persistance, idempotence, désinscription AUTORITATIVE). La CONVERGENCE
|
||||
# du compteur dérivé (1→2 sans reload) est validée là où elle a du sens — le
|
||||
# PROPRIÉTAIRE matérialise l'inbox et recalcule participantCount =
|
||||
# |inscriptions actives distinctes| sur SON propre doc (PAS de base « hôte » : le
|
||||
# créateur ne participe pas automatiquement), de façon RÉACTIVE et cross-session.
|
||||
# Ce que le @data mono-session prouve ici : la participation elle-même
|
||||
# (persistance, idempotence, désinscription AUTORITATIVE). La CONVERGENCE du
|
||||
# compteur dérivé (0→1 sans reload) est validée là où elle a du sens — le
|
||||
# scénario @multibrowser réactif (e2e-multibrowser.feature « Un participant apparaît
|
||||
# réactivement… »), avec un vrai propriétaire (A) et un vrai inscrit (B).
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
// Creator does NOT auto-participate (@data). After createEvent, the creator's
|
||||
// isParticipating(E) === false and participantCount === 0; the creator can then
|
||||
// join (→ true) and leave (→ false) their own event like anyone else.
|
||||
|
||||
Given('le créateur relaie l\'événement {string}', { timeout: 180000 }, async function (this: FestipodWorld, title: string) {
|
||||
const out = await this.appFrame!.evaluate(async (title) => {
|
||||
const td = (window as any).__testData;
|
||||
await td.ensureCurrentUser();
|
||||
const created = await td.createEventReal(title);
|
||||
return { eventId: created.id };
|
||||
}, title);
|
||||
(this as any).creatorEventId = out.eventId;
|
||||
(this as any).creatorEventTitle = title;
|
||||
});
|
||||
|
||||
Then('le créateur n\'est pas participant de l\'événement {string}', { timeout: 60000 }, async function (this: FestipodWorld, title: string) {
|
||||
const eventId = (this as any).creatorEventId;
|
||||
// Authoritative: the broker must hold 0 participations for (E, creator). Poll a
|
||||
// little to absorb any pending write from a just-run leave.
|
||||
const n = await this.appFrame!.evaluate(async ({ eventId }: { eventId: string }) => {
|
||||
const td = (window as any).__testData;
|
||||
const uid = await td.ensureCurrentUser();
|
||||
let last = -1;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
last = await td.authParticipationCount(eventId, uid);
|
||||
if (last === 0) return 0;
|
||||
await new Promise(r => setTimeout(r, 750));
|
||||
}
|
||||
return last;
|
||||
}, { eventId });
|
||||
expect(n, `creator must NOT be participating in "${title}" (broker count)`).to.equal(0);
|
||||
});
|
||||
|
||||
Then('le compteur de participants de l\'événement {string} vaut {int}', { timeout: 60000 }, async function (this: FestipodWorld, title: string, expected: number) {
|
||||
const eventId = (this as any).creatorEventId;
|
||||
// The count is DERIVED by the owner materializing the inbox. On a freshly
|
||||
// created event with no joins, no deposit exists → the reactive count reflects
|
||||
// the create-time value (0). Read the reactive event state.
|
||||
const count = await this.appFrame!.evaluate((eventId: string) => {
|
||||
const td = (window as any).__testData;
|
||||
const ev = td.getEvent(eventId);
|
||||
return ev ? (ev.participantCount ?? -1) : -2;
|
||||
}, eventId);
|
||||
expect(count, `participantCount of "${title}" must be ${expected}`).to.equal(expected);
|
||||
});
|
||||
|
||||
When('le créateur rejoint son événement {string}', { timeout: 120000 }, async function (this: FestipodWorld, _title: string) {
|
||||
const eventId = (this as any).creatorEventId;
|
||||
await this.appFrame!.evaluate(async ({ eventId }: { eventId: string }) => {
|
||||
const td = (window as any).__testData;
|
||||
const uid = await td.ensureCurrentUser();
|
||||
await td.appJoinEvent(eventId, uid);
|
||||
}, { eventId });
|
||||
});
|
||||
|
||||
When('le créateur quitte son événement {string}', { timeout: 120000 }, async function (this: FestipodWorld, _title: string) {
|
||||
const eventId = (this as any).creatorEventId;
|
||||
await this.appFrame!.evaluate(async ({ eventId }: { eventId: string }) => {
|
||||
const td = (window as any).__testData;
|
||||
const uid = await td.ensureCurrentUser();
|
||||
await td.appLeaveEvent(eventId, uid);
|
||||
}, { eventId });
|
||||
});
|
||||
|
||||
Then('le créateur est participant de l\'événement {string}', { timeout: 60000 }, async function (this: FestipodWorld, title: string) {
|
||||
const eventId = (this as any).creatorEventId;
|
||||
const n = await this.appFrame!.evaluate(async ({ eventId }: { eventId: string }) => {
|
||||
const td = (window as any).__testData;
|
||||
const uid = await td.ensureCurrentUser();
|
||||
let last = 0;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
last = await td.authParticipationCount(eventId, uid);
|
||||
if (last >= 1) return last;
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
return last;
|
||||
}, { eventId });
|
||||
expect(n, `creator must be participating in "${title}" after joining`).to.equal(1);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
import { pool } from '../../../../shared/support/browserPool';
|
||||
|
||||
// Two-identity isolation (@data, real broker). Identity A (the fresh per-scenario
|
||||
// username set in localStorage) creates an event E and joins it; a genuinely-
|
||||
// different identity B is brought up on the SAME wallet; B must read NONE of A's
|
||||
// protected participation, E must not be on B's home, isParticipating(E,B) false.
|
||||
//
|
||||
// B is brought up via a FRESH PAGE on the SAME persistent wallet context with B's
|
||||
// identifier in localStorage — the closest analogue to the real app's re-enter-
|
||||
// gate / reload path (a brand-new NgDataProvider mount, username=B, on a wallet
|
||||
// that already holds A's docs). This exercises the identity-switch reset that
|
||||
// keeps A's protected docs out of B's read set.
|
||||
|
||||
Given('l\'identité A crée l\'événement {string} et s\'y inscrit', { timeout: 180000 }, async function (this: FestipodWorld, title: string) {
|
||||
const out = await this.appFrame!.evaluate(async (title) => {
|
||||
const td = (window as any).__testData;
|
||||
const aId = await td.ensureCurrentUser();
|
||||
const created = await td.createEventReal(title);
|
||||
await td.appJoinEvent(created.id, aId);
|
||||
// Wait until A's own participation is in A's reactive set (authoritative-ish).
|
||||
for (let i = 0; i < 30; i++) {
|
||||
if (td.isParticipating(created.id, aId)) break;
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
return { eventId: created.id, aId, aParticipates: td.isParticipating(created.id, aId) };
|
||||
}, title);
|
||||
expect(out.aParticipates, 'A must be participating in its own event before B arrives').to.be.true;
|
||||
(this as any).isoEventId = out.eventId;
|
||||
(this as any).isoEventTitle = title;
|
||||
(this as any).isoAId = out.aId;
|
||||
});
|
||||
|
||||
When('une identité fraîche B arrive sur le même wallet partagé', { timeout: 120000 }, async function (this: FestipodWorld) {
|
||||
const bId = `iso-b-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
(this as any).isoBId = bId;
|
||||
const ctx = this.page!.context();
|
||||
const bPage = await ctx.newPage();
|
||||
await bPage.addInitScript((u: string) => {
|
||||
try { window.localStorage.setItem('festipod.account.username', u); } catch { /* opaque */ }
|
||||
}, bId);
|
||||
await bPage.addInitScript(() => {
|
||||
(globalThis as Record<string, unknown>).__FESTIPOD_ACCESS_GATE_DISABLED__ = true;
|
||||
});
|
||||
bPage.on('console', (msg) => { if (msg.type() === 'error') console.error('[Bpage console]', msg.text()); });
|
||||
const bFrame = await pool.setupBrokerPage!(bPage, pool.harnessUrl!);
|
||||
await bFrame.waitForFunction(() => (window as any).__testData?.ready === true, { timeout: 60000 });
|
||||
// Let B's listing effect + union read run (rebuilds the read set bounded to B).
|
||||
await bFrame.evaluate(async () => {
|
||||
const td = (window as any).__testData;
|
||||
await td.ensureCurrentUser();
|
||||
await new Promise(r => setTimeout(r, 6000));
|
||||
});
|
||||
(this as any).isoBFrame = bFrame;
|
||||
});
|
||||
|
||||
Then('l\'événement {string} n\'est pas sur l\'accueil de B', async function (this: FestipodWorld, title: string) {
|
||||
const bFrame = (this as any).isoBFrame;
|
||||
const onHome = await bFrame.evaluate((title: string) => {
|
||||
const td = (window as any).__testData;
|
||||
return td.homeEventTitles().includes(title);
|
||||
}, title);
|
||||
expect(onHome, `"${title}" must NOT appear on B's home (getUserEvents(B))`).to.be.false;
|
||||
});
|
||||
|
||||
Then('B n\'est pas participant de l\'événement {string}', async function (this: FestipodWorld, title: string) {
|
||||
const bFrame = (this as any).isoBFrame;
|
||||
const eventId = (this as any).isoEventId;
|
||||
const isPart = await bFrame.evaluate(async ({ eventId }: { eventId: string }) => {
|
||||
const td = (window as any).__testData;
|
||||
const bId = await td.ensureCurrentUser();
|
||||
return td.isParticipating(eventId, bId);
|
||||
}, { eventId });
|
||||
expect(isPart, `B must NOT be participating in "${title}"`).to.be.false;
|
||||
});
|
||||
|
||||
Then('B ne lit aucune participation de A', async function (this: FestipodWorld) {
|
||||
const bFrame = (this as any).isoBFrame;
|
||||
const aId = (this as any).isoAId;
|
||||
const leaks = await bFrame.evaluate((aId: string) => {
|
||||
const td = (window as any).__testData;
|
||||
return td.currentParticipations().filter((p: any) => p.userId === aId);
|
||||
}, aId);
|
||||
expect(leaks, `B must read NONE of A's protected participations (found ${JSON.stringify(leaks)})`).to.have.lengthOf(0);
|
||||
});
|
||||
@@ -533,8 +533,8 @@ function useNgData(): FestipodDataContextValue {
|
||||
// IDEMPOTENCE / CONVERGENCE: the count is DERIVED from the SET of distinct
|
||||
// active registrations (`materializeAttendance`: distinct join uids MINUS
|
||||
// cancelled ones), never an unbounded ±1. A broker re-sync replays the same
|
||||
// deposits → same set → same count. `participantCount = 1 (host self, the
|
||||
// create-time baseline) + activeRegistrations.size`. The write is GUARDED
|
||||
// deposits → same set → same count. `participantCount = activeRegistrations.size`
|
||||
// (no host baseline — the creator does not auto-participate). The write is GUARDED
|
||||
// (write only when the value actually changes) so re-materializing an unchanged
|
||||
// inbox does not thrash the doc / loop the reactive read.
|
||||
//
|
||||
@@ -575,7 +575,7 @@ function useNgData(): FestipodDataContextValue {
|
||||
// (1) COUNT — derive the distinct active-registration set for this event
|
||||
// and write it on MY OWN event doc (only when it changed).
|
||||
const active = await materializeAttendance(targetInbox, evId);
|
||||
const nextCount = 1 + active.length; // 1 = host self (create baseline)
|
||||
const nextCount = active.length; // no host baseline (creator not auto-in)
|
||||
if (materializedCountRef.current.get(evId) !== nextCount) {
|
||||
materializedCountRef.current.set(evId, nextCount);
|
||||
await updateEntityField(evId, evId, 'participantCount', int(nextCount))
|
||||
@@ -682,22 +682,20 @@ function useNgData(): FestipodDataContextValue {
|
||||
const eventId = await writeEntity(eventGraph, ENTITY_TYPE.event, {
|
||||
title: str(event.title), description: str(event.description), date: str(event.date),
|
||||
location: str(event.location), distance: flt(event.distance),
|
||||
participantCount: int(event.participantCount || 1),
|
||||
// No host notion: the creator merely SIGNALS a public event and is NOT
|
||||
// obliged to participate, so the count starts at 0 (the owner-materializer
|
||||
// derives it from the active-registration set — |active|, no host baseline).
|
||||
participantCount: int(event.participantCount || 0),
|
||||
coverImage: str(event.coverImage), hostName: str(event.hostName), hostInitials: str(event.hostInitials),
|
||||
});
|
||||
registerDoc('public', eventGraph);
|
||||
// OPTION B: this event's doc is MINE (I just created it), so track it as owned
|
||||
// → the owner-materializer subscribes to its inbox and maintains its count.
|
||||
setOwnedEventIds(prev => (prev.includes(eventGraph) ? prev : [...prev, eventGraph]));
|
||||
if (currentUserId) {
|
||||
// The host's participation is its OWN document in the PROTECTED scope.
|
||||
const partGraph = await createEntityDoc(owner, 'protected');
|
||||
await writeEntity(partGraph, ENTITY_TYPE.participation, {
|
||||
event: iri(eventId), user: iri(currentUserId), isConfirmed: bool(true),
|
||||
});
|
||||
registerDoc('protected', partGraph);
|
||||
setSelectedEventId(eventId);
|
||||
}
|
||||
// The creator does NOT auto-participate (no host notion — settled product
|
||||
// decision): NO participation is written on create. The creator sees "J'y
|
||||
// serai" and may join/leave their own event like anyone else.
|
||||
if (currentUserId) setSelectedEventId(eventId);
|
||||
const addedEvent = { "@id": eventId, title: event.title };
|
||||
// Make the PUBLIC event discoverable: submit its reference to the SDK global
|
||||
// discovery index (an SDK act — the app holds no index/store id). The SDK
|
||||
|
||||
@@ -234,9 +234,9 @@ export interface ActiveRegistration {
|
||||
* set — it can never resurrect a phantom count.
|
||||
*
|
||||
* The owner then writes `participantCount` on its OWN event doc as
|
||||
* `1 (host self, from create) + activeRegistrations.size`. The host's own
|
||||
* participation is the create-time baseline (never deposited into the inbox), so
|
||||
* it is added here rather than derived from a deposit.
|
||||
* `activeRegistrations.size`. There is NO host baseline: the creator merely
|
||||
* signals a public event and is NOT obliged to participate (no host notion), so
|
||||
* the count is 0 until someone joins, and the creator may join/leave like anyone.
|
||||
*/
|
||||
export async function materializeAttendance(
|
||||
targetInbox: string,
|
||||
|
||||
@@ -87,6 +87,15 @@ function HarnessRouter() {
|
||||
function ConnectedHarness() {
|
||||
const ngCtx = useNextGraph();
|
||||
const appData = useFestipodData();
|
||||
// Identity switch (two-identity isolation): the app has no page reload on a
|
||||
// faux-logout+re-login (shared-wallet stopgap), so switching identity here
|
||||
// means calling AccountContext.login() with a new identifier — which drives the
|
||||
// `prevOwnerRef` reset effect in FestipodDataContext. Exposed to steps so a @data
|
||||
// scenario can bring up identity A, then a genuinely-different identity B on the
|
||||
// SAME wallet and assert B is isolated.
|
||||
const account = useAccount();
|
||||
const accountRef = useRef(account);
|
||||
accountRef.current = account;
|
||||
// The bridge is built once inside an effect (below) and its getters close over
|
||||
// `appData`. `appData` is a NEW object every render (its `events`/`users` reflect
|
||||
// the latest per-entity reads), so a captured snapshot goes STALE — after
|
||||
@@ -169,6 +178,33 @@ function ConnectedHarness() {
|
||||
get currentUserId() { return AD().currentUserId || currentUserId; },
|
||||
session,
|
||||
|
||||
// --- IDENTITY SWITCH (two-identity isolation) ----------------------
|
||||
/** Faux-logout + re-login under a NEW identifier on the SAME wallet (no
|
||||
* page reload), exactly as the real app's AccessGate/Settings flow does.
|
||||
* Drives AccountContext.login → setCurrentUser + the FestipodDataContext
|
||||
* `prevOwnerRef` reset. Returns the normalized id now in effect. */
|
||||
switchIdentity(identifier: string) {
|
||||
accountRef.current.login(identifier);
|
||||
return normalizeUsername(identifier);
|
||||
},
|
||||
/** The current app-level identifier (localStorage-backed). */
|
||||
currentIdentifier() {
|
||||
return accountRef.current.username;
|
||||
},
|
||||
/** Titles of the events the CURRENT user PARTICIPATES in — exactly what the
|
||||
* HOME screen shows (`getUserEvents(currentUserId)`). Used by the
|
||||
* two-identity isolation test to assert a fresh identity's home is empty. */
|
||||
homeEventTitles() {
|
||||
const ad = AD();
|
||||
return ad.getUserEvents(ad.currentUserId).map(e => e.title);
|
||||
},
|
||||
/** The current user's participation rows (userId+eventId), the reactive set
|
||||
* the screens read. Used to assert a fresh identity reads NONE of another
|
||||
* identity's protected participations. */
|
||||
currentParticipations() {
|
||||
return AD().participations.map(p => ({ userId: p.userId, eventId: p.eventId }));
|
||||
},
|
||||
|
||||
// --- App-level view (through real providers, same as what screens see) ---
|
||||
appData,
|
||||
ngStatus: ngCtx.status,
|
||||
|
||||
Reference in New Issue
Block a user