Log out when the app was removed from the pod instead of re-asking consent

A registration read as installed:false means the user removed Festipod from their pod, often on purpose: re-running registerApp sent them to consent on every load with no way to sign out. The probe now logs out through the provider and lands on the picker with a notice that the authorization was removed and they can sign in again; Back and reload stay there. upgradeNeeded:true keeps the automatic consent redirect. The revocation e2e asserts the new path and fails on the previous one.
This commit is contained in:
Sylvain Duchesne
2026-10-12 16:27:02 +02:00
parent f32e166601
commit d4fcb7e311
4 changed files with 147 additions and 37 deletions
+45 -17
View File
@@ -14,14 +14,16 @@
* lands at the end of the minute and reads current.
* 2. REVOKED — the registration is removed on the pod
* (`POST {pod}/.auth-agent/app-registrations/remove`, the user's own token).
* (a) Without any reload, a window focus inside the minute — the user coming
* Without any reload, a window focus inside the minute — the user coming
* back from the pod's tab — must, by the end of that minute, read the
* revocation and take the app off the connected view: the consent screen
* (the provider's `registerApp` redirect) or the picker. (b) Reloading the
* app on the event form must do the same through the load probe, and the
* event-creation drive that worked in phase 1 must not reach success: no
* "Événement relayé", no event page, and nothing with that title on the pod.
* 3. RECOVERED — accepting the consent screen brings the user back to a
* revocation (`installed:false`) and LOG THE USER OUT: the picker with the
* notice that the app's authorization was removed, NO redirect to the
* consent screen (a deliberate revocation is not sent back to consent), no
* session left (Back and a fresh load stay on the picker; the event-creation
* drive that worked in phase 1 lands on the picker, never on success, and
* nothing with that title reaches the pod).
* 3. RECOVERED — signing in again from the picker goes through
* `registerApp` → the consent screen; accepting it brings the user back to a
* connected app whose probe reads current again, and a write lands.
*
* Nothing is stubbed: the probe result is whatever the pod answers. The probe
@@ -314,22 +316,40 @@ function benign(e: HttpError): boolean {
const leftByFocusTo = await whereIs(page);
await snap(page, 'revocation-revoked-left-by-focus');
// (b) RELOAD on the write path — the same event-creation drive that worked in
// phase 1: the load probe must take the app off again, and nothing may
// pretend the write succeeded.
const notice = page.locator('[data-testid="auth-picker-notice"]');
const noticeShown = onApp(page) && (await notice.count().catch(() => 0)) > 0;
const noticeText = noticeShown ? ((await notice.first().innerText().catch(() => '')) as string).replace(/\s+/g, ' ').trim() : '';
// (b) No session left: Back must not return to the connected view, a fresh
// load must stay on the picker, and the same event-creation drive that worked
// in phase 1 must not reach success (it lands on the picker).
await page.goBack({ waitUntil: 'domcontentloaded' }).catch(() => {});
await page.waitForTimeout(2000);
const stillPickerAfterBack = (await whereIs(page)) === 'picker';
await page.goto(`${APP}/`, { waitUntil: 'domcontentloaded' }).catch(() => {});
await page.waitForTimeout(2500);
const stillPickerAfterReload = (await whereIs(page)) === 'picker';
const titleB = `Concert revoque ${USER}`;
const loadLogsBefore = probeLogs.length;
const writeB = await createEvent(page, titleB);
await snap(page, 'revocation-revoked-after-write-attempt');
const leftAfterReload = await waitFor(async () => !onApp(page) || (await pickerShown(page)), 15000);
const leftAfterReloadTo = await whereIs(page);
const loadProbeSawRevoked = probeLogs.slice(loadLogsBefore).some(l => /probe \(load\) → stale \(not-installed\)/.test(l.text));
const writeBOnPod = await podHoldsEventTitled(user, titleB);
const consentRedirectInRevoked = redirects.some(r => r.startsWith('[revoked]') && r.includes(`${clean(POD_FRONT)}/authorize`));
const pageWritesInRevoked = writes.filter(w => w.phase === 'revoked');
await snap(page, 'revocation-revoked-left');
// ── Phase 3: RECOVERED ──────────────────────────────────────────────────
phase = 'recovered';
const reconnected = leftAfterReloadTo === 'consent' ? await driveToApp(page) : false;
// Sign in again from the picker: login → registerApp → consent → app.
const signInAgain = page.getByRole('button', { name: /Se connecter avec localhost:3000/i });
let reconnected = false;
if (leftAfterReloadTo === 'picker' && (await signInAgain.count().catch(() => 0))) {
await signInAgain.first().click().catch(() => {});
await page.waitForTimeout(1500);
reconnected = await driveToApp(page);
}
const consentReachedOnSignIn = hops.some(h => h.startsWith(' [recovered] [authorize'));
const probeReadsCurrentAgain = reconnected && await waitFor(
() => statusSeen.some(s => s.phase === 'recovered' && s.status === 200 && isCurrent(s.body)),
15000,
@@ -364,20 +384,28 @@ function benign(e: HttpError): boolean {
focusProbe,
leftByFocus,
leftByFocusTo,
loadProbeSawRevoked,
noticeShown,
noticeText,
consentRedirectInRevoked,
pageWritesInRevoked,
stillPickerAfterBack,
stillPickerAfterReload,
leftAfterReload,
leftAfterReloadTo,
writeAttempt: { ...writeB, onPod: writeBOnPod },
ok: (revoke.status === 200 || revoke.status === 204)
&& deferredOnTime(focusProbe) && leftByFocus && (leftByFocusTo === 'consent' || leftByFocusTo === 'picker')
&& loadProbeSawRevoked && leftAfterReload && (leftAfterReloadTo === 'consent' || leftAfterReloadTo === 'picker')
&& deferredOnTime(focusProbe) && leftByFocus && leftByFocusTo === 'picker'
&& noticeShown && noticeText.includes('retirée')
&& !consentRedirectInRevoked && !pageWritesInRevoked.length
&& stillPickerAfterBack && stillPickerAfterReload && leftAfterReloadTo === 'picker'
&& !writeB.eventUri && !writeB.successToast && !writeBOnPod,
};
const recovered = {
reconnected,
consentReachedOnSignIn,
probeReadsCurrentAgain,
write: { ...writeC, onPod: writeCOnPod },
ok: reconnected && probeReadsCurrentAgain && !!writeC.eventUri && writeC.successToast && writeCOnPod,
ok: reconnected && consentReachedOnSignIn && probeReadsCurrentAgain && !!writeC.eventUri && writeC.successToast && writeCOnPod,
};
// Phase 2's ≥400s are the revocation's own consequences; the other phases must be clean.
const fatal = errors4xx.filter(e => e.phase !== 'revoked' && !benign(e));