Detect a revoked or stale app registration and leave the broken connected view
While a session exists, the app probes getAppStatus() on load and on focus/visibility, at most once a minute (a trigger inside the minute is deferred, not dropped), failing open on errors. installed:false or upgradeNeeded:true re-runs registerApp, whose consent redirect is the recovery; meanwhile the app view is replaced by a renewal screen, and an unrecoverable registration lands on the picker with a notice. bun run test:e2e-revocation proves both sides on the live stack: a current registration stays usable, a revoked one leaves the connected state and its writes no longer land.
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
"cucumber:run": "node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js --config cucumber.json",
|
||||
"test:auth-setup": "bun scripts/setup-test-auth.ts",
|
||||
"test:e2e-flow": "bun scripts/e2e-login-flow.ts",
|
||||
"test:e2e-revocation": "bun scripts/e2e-revocation.ts",
|
||||
"cucumber:report": "bun scripts/parse-test-results.ts",
|
||||
"features:parse": "bun scripts/parse-features.ts",
|
||||
"steps:extract": "bun scripts/extract-step-definitions.ts",
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* Revocation probe: drives the REAL local ActivityPods stack through the browser
|
||||
* and asserts that Festipod notices when its registration on the user's pod is
|
||||
* revoked, instead of staying in a "connected" view whose writes cannot land.
|
||||
*
|
||||
* THREE PHASES, one fresh user:
|
||||
*
|
||||
* 1. CURRENT — picker → pod login → consent → app. The app-status probe runs on
|
||||
* load (the GET of the pod's `/.well-known/app-status` is observed on the
|
||||
* wire, with its body, and the app logs `[AppStatus] probe (load) → current`);
|
||||
* a focus + visibilitychange right after does NOT probe at once (one-minute
|
||||
* throttle); an event is created and read back from the pod; a
|
||||
* visibilitychange inside the minute is deferred, not dropped — its probe
|
||||
* 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
|
||||
* 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
|
||||
* connected app whose probe reads current again, and a write lands.
|
||||
*
|
||||
* Nothing is stubbed: the probe result is whatever the pod answers. The probe
|
||||
* is triggered only through the app's real paths (page load, window focus,
|
||||
* visibilitychange); its one-minute throttle is untouched.
|
||||
*
|
||||
* ORIGIN. As for `e2e-login-flow.ts`: the OIDC redirect_uri is registered for
|
||||
* `http://localhost:4000/login` only, so the app MUST be served at APP_URL.
|
||||
*
|
||||
* Exit code 0 only when every phase holds; the JSON report says which did not.
|
||||
*/
|
||||
|
||||
import { chromium, type Page } from 'playwright';
|
||||
import {
|
||||
linkId,
|
||||
podLogin,
|
||||
readResource,
|
||||
recordUri,
|
||||
type ApodsSession,
|
||||
type ApodsStackConfig,
|
||||
} from '../src/shared/support/activitypodsStack';
|
||||
|
||||
const strip = (u: string): string => u.replace(/\/+$/, '');
|
||||
const BACKEND: string = strip(process.env.BACKEND_URL || 'http://localhost:3003');
|
||||
const APP: string = strip(process.env.APP_URL || 'http://localhost:4000');
|
||||
const config: ApodsStackConfig = {
|
||||
podProviderUrl: strip(process.env.POD_PROVIDER_URL || 'http://localhost:3000'),
|
||||
backendUrl: BACKEND,
|
||||
};
|
||||
const POD_FRONT = strip(process.env.POD_FRONTEND_URL || 'http://localhost:5000');
|
||||
|
||||
const USER = `revoke${Date.now()}`;
|
||||
const PASS = 'Test1test';
|
||||
const DEADLINE = Date.now() + 10 * 60 * 1000;
|
||||
|
||||
type Phase = 'current' | 'revoked' | 'recovered';
|
||||
let phase: Phase = 'current';
|
||||
|
||||
interface StatusSeen { phase: Phase; status: number; body: unknown }
|
||||
interface HttpError { phase: Phase; url: string; status: number; method: string; body: string }
|
||||
|
||||
const statusSeen: StatusSeen[] = [];
|
||||
const probeLogs: Array<{ phase: Phase; at: number; text: string }> = [];
|
||||
const errors4xx: HttpError[] = [];
|
||||
const writes: Array<{ phase: Phase; method: string; url: string; status: number }> = [];
|
||||
const consoleErrors: string[] = [];
|
||||
const redirects: string[] = [];
|
||||
const hops: string[] = [];
|
||||
|
||||
const clean = (s: string): string => s.replace('http://localhost:', ':').replace('http://127.0.0.1:', ':127.');
|
||||
const bodyText = (page: Page): Promise<string> =>
|
||||
page.locator('body').innerText().catch(() => '').then(t => t.replace(/\s+/g, ' ').trim());
|
||||
const snap = (page: Page, name: string): Promise<void> =>
|
||||
page.screenshot({ path: `/tmp/${name}.png`, fullPage: true }).then(() => undefined).catch(() => undefined);
|
||||
const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
function wire(page: Page): void {
|
||||
let last = '';
|
||||
page.on('framenavigated', f => {
|
||||
if (f === page.mainFrame() && f.url() !== last) {
|
||||
redirects.push(`[${phase}] ${clean(last || '(start)')} -> ${clean(f.url())}`);
|
||||
last = f.url();
|
||||
}
|
||||
});
|
||||
page.on('response', r => {
|
||||
const method = r.request()?.method?.() ?? '?';
|
||||
const url = r.url();
|
||||
if (method === 'GET' && url.endsWith('/.well-known/app-status')) {
|
||||
const at = phase;
|
||||
r.json()
|
||||
.then(body => statusSeen.push({ phase: at, status: r.status(), body }))
|
||||
.catch(() => statusSeen.push({ phase: at, status: r.status(), body: '(unreadable)' }));
|
||||
}
|
||||
if ((method === 'POST' || method === 'PUT' || method === 'PATCH') && url.startsWith(config.podProviderUrl)) {
|
||||
writes.push({ phase, method, url: clean(url), status: r.status() });
|
||||
}
|
||||
if (r.status() >= 400) {
|
||||
const at = phase;
|
||||
r.text()
|
||||
.then(b => errors4xx.push({ phase: at, url, status: r.status(), method, body: b.slice(0, 300) }))
|
||||
.catch(() => errors4xx.push({ phase: at, url, status: r.status(), method, body: '(unreadable)' }));
|
||||
}
|
||||
});
|
||||
page.on('console', m => {
|
||||
const text = m.text();
|
||||
if (text.includes('[AppStatus]')) probeLogs.push({ phase, at: Date.now(), text: text.slice(0, 400) });
|
||||
if (m.type() === 'error') consoleErrors.push(`[${phase}] ${text.slice(0, 300)}`);
|
||||
});
|
||||
page.on('pageerror', e => consoleErrors.push(`[${phase}] pageerror: ${String(e.message).slice(0, 300)}`));
|
||||
}
|
||||
|
||||
const onApp = (page: Page): boolean => page.url().startsWith(APP);
|
||||
const pickerShown = async (page: Page): Promise<boolean> =>
|
||||
(await page.locator('text=Connectez-vous avec votre Pod').count().catch(() => 0)) > 0;
|
||||
const whereIs = async (page: Page): Promise<string> =>
|
||||
!onApp(page)
|
||||
? (page.url().startsWith(`${POD_FRONT}/authorize`) ? 'consent' : `elsewhere:${clean(page.url())}`)
|
||||
: (await pickerShown(page)) ? 'picker' : 'still-connected';
|
||||
|
||||
/** Drive pod login → consent until the connected app shell is reached. */
|
||||
async function driveToApp(page: Page): Promise<boolean> {
|
||||
let authorizeSeen = 0;
|
||||
for (let i = 0; i < 25 && Date.now() < DEADLINE; i++) {
|
||||
const url = page.url();
|
||||
if (hops[hops.length - 1] !== `[${phase}] ${clean(url)}`) hops.push(`[${phase}] ${clean(url)}`);
|
||||
|
||||
if (url.startsWith(POD_FRONT) && (await page.locator('input[name="username"]').count().catch(() => 0))) {
|
||||
hops.push(` [${phase}] [login] submitting credentials`);
|
||||
await page.locator('input[name="username"]').fill(USER).catch(() => {});
|
||||
await page.locator('input[name="password"]').fill(PASS).catch(() => {});
|
||||
const btn = page.getByRole('button', { name: /SE CONNECTER|Se connecter|Sign in|Connexion/i });
|
||||
if (await btn.count()) await btn.first().click().catch(() => {});
|
||||
else await page.locator('button[type=submit]').first().click().catch(() => {});
|
||||
await page.waitForTimeout(3500);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (url.startsWith(`${POD_FRONT}/authorize`)) {
|
||||
authorizeSeen++;
|
||||
const acc = page.getByRole('button', { name: /^ACCEPTER$|^Accepter$|^Accept$/i });
|
||||
const visible = await acc.first().isVisible({ timeout: 5000 }).catch(() => false);
|
||||
hops.push(` [${phase}] [authorize #${authorizeSeen}] accept visible=${visible}`);
|
||||
await snap(page, `revocation-${phase}-consent`);
|
||||
if (visible) await acc.first().click().catch(() => {});
|
||||
if (authorizeSeen >= 3) { hops.push(` [${phase}] [LOOP] authorize revisited 3x`); return false; }
|
||||
await page.waitForTimeout(5000);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (onApp(page)) {
|
||||
if (url.includes('/login')) { await page.waitForTimeout(3000); continue; }
|
||||
if (!(await pickerShown(page)) && !(await page.locator('#auth-reauthorizing').count().catch(() => 0))) {
|
||||
hops.push(` [${phase}] [app] shell reached`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The event-creation drive from `e2e-login-flow.ts`, reporting what the app showed. */
|
||||
async function createEvent(page: Page, title: string): Promise<{ eventUri?: string; successToast: boolean; finalUrl: string }> {
|
||||
let successToast = false;
|
||||
const toastWatch = setInterval(() => {
|
||||
void page.locator('text=Événement relayé').count().then(n => { if (n) successToast = true; }).catch(() => {});
|
||||
}, 250);
|
||||
try {
|
||||
await page.goto(`${APP}/events/new`, { waitUntil: 'domcontentloaded' }).catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
await page.locator('input[placeholder="Donnez un nom à votre événement"]').fill(title, { timeout: 3000 }).catch(() => {});
|
||||
const dates = page.locator('input[type="date"]');
|
||||
if (await dates.count().catch(() => 0)) await dates.first().fill('2026-12-20', { timeout: 3000 }).catch(() => {});
|
||||
await page.getByRole('button', { name: /Suivant/ }).click({ timeout: 3000 }).catch(() => {});
|
||||
await page.waitForTimeout(1200);
|
||||
const next = page.getByRole('button', { name: /Suivant/ });
|
||||
if (await next.count().catch(() => 0)) await next.click({ timeout: 3000 }).catch(() => {});
|
||||
await page.waitForTimeout(1200);
|
||||
await page.getByRole('button', { name: /Relayer l'événement/ }).click({ timeout: 3000 }).catch(() => {});
|
||||
await page.waitForTimeout(5000);
|
||||
} finally {
|
||||
clearInterval(toastWatch);
|
||||
}
|
||||
const finalUrl = page.url();
|
||||
const m = finalUrl.startsWith(`${APP}/events/`) ? /\/events\/([^/?#]+)/.exec(finalUrl.slice(APP.length)) : null;
|
||||
const segment = m?.[1];
|
||||
const eventUri = segment && segment !== 'new' ? decodeURIComponent(segment) : undefined;
|
||||
return { eventUri, successToast, finalUrl };
|
||||
}
|
||||
|
||||
/** Does the user's pod hold an event with this title? Read with the user's own token. */
|
||||
async function podHoldsEventTitled(session: ApodsSession, title: string): Promise<boolean> {
|
||||
const container = await readResource(session, `${session.storage}/as/event`).catch(() => undefined);
|
||||
if (!container) return false;
|
||||
if (JSON.stringify(container).includes(title)) return true;
|
||||
const raw = container['ldp:contains'] ?? container['contains'];
|
||||
const members = (Array.isArray(raw) ? raw : raw ? [raw] : []).map(linkId).filter((u): u is string => !!u);
|
||||
for (const uri of members) {
|
||||
const rec = await readResource(session, uri).catch(() => undefined);
|
||||
if (rec && JSON.stringify(rec).includes(title)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Wait until a predicate holds or the bound elapses. */
|
||||
async function waitFor(pred: () => boolean | Promise<boolean>, ms: number): Promise<boolean> {
|
||||
const end = Date.now() + ms;
|
||||
while (Date.now() < end) {
|
||||
if (await pred()) return true;
|
||||
await sleep(250);
|
||||
}
|
||||
return pred();
|
||||
}
|
||||
|
||||
/** Fire the real DOM event a probe listener is attached to. */
|
||||
const fire = (page: Page, trigger: 'focus' | 'visibility'): Promise<void> =>
|
||||
page.evaluate((which) => {
|
||||
if (which === 'focus') window.dispatchEvent(new Event('focus'));
|
||||
else document.dispatchEvent(new Event('visibilitychange'));
|
||||
}, trigger).catch(() => {});
|
||||
|
||||
const lastProbeAt = (): number =>
|
||||
Math.max(0, ...probeLogs.filter(l => l.text.includes('probe (')).map(l => l.at));
|
||||
|
||||
/**
|
||||
* The probe's own schedule, exercised live and never shortened: fire `trigger`
|
||||
* less than a minute after the app's last probe, check that NO probe starts at
|
||||
* once (throttle), then wait for the deferred probe of that trigger to land
|
||||
* with the expected verdict at the end of the minute (not dropped).
|
||||
*/
|
||||
async function deferredProbe(page: Page, trigger: 'focus' | 'visibility', verdict: RegExp): Promise<{
|
||||
firedAfterLastProbeMs: number; immediate: boolean; probed: boolean; gapFromLastProbeMs?: number;
|
||||
}> {
|
||||
const last = lastProbeAt();
|
||||
const firedAfterLastProbeMs = Date.now() - last;
|
||||
const before = probeLogs.length;
|
||||
await fire(page, trigger);
|
||||
await page.waitForTimeout(3000);
|
||||
const immediate = probeLogs.slice(before).some(l => l.text.includes('probe ('));
|
||||
const pattern = new RegExp(`probe \\(${trigger}\\) → ${verdict.source}`);
|
||||
const probed = await waitFor(() => probeLogs.slice(before).some(l => pattern.test(l.text)), 75_000);
|
||||
const hit = probeLogs.slice(before).find(l => pattern.test(l.text));
|
||||
return { firedAfterLastProbeMs, immediate, probed, gapFromLastProbeMs: hit ? hit.at - last : undefined };
|
||||
}
|
||||
|
||||
const isCurrent = (b: unknown): boolean =>
|
||||
typeof b === 'object' && b !== null && (b as Record<string, unknown>)['installed'] === true && (b as Record<string, unknown>)['upgradeNeeded'] === false;
|
||||
|
||||
/** Benign ≥400 by design (same classes as e2e-login-flow.ts). */
|
||||
function benign(e: HttpError): boolean {
|
||||
if (e.url.includes('/.notifications/WebSocketChannel2023')) return true;
|
||||
if (e.status === 403 && e.method === 'GET' && /\/data\//.test(e.url)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const user = await podLogin(config, USER, PASS);
|
||||
const appDescription = await readResource(undefined, `${BACKEND}/app`);
|
||||
const appUri = recordUri(appDescription);
|
||||
if (!appUri) throw new Error('GET /app carries no id (the app actor URI)');
|
||||
|
||||
const browser = await chromium.launch({ headless: true, args: ['--allow-insecure-localhost', '--disable-web-security'] });
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
wire(page);
|
||||
|
||||
// ── Phase 1: CURRENT ────────────────────────────────────────────────────
|
||||
await page.goto(`${APP}/`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(1500);
|
||||
await page.getByRole('button', { name: /Se connecter avec localhost:3000/i }).click();
|
||||
const connected = await driveToApp(page);
|
||||
const probeRanCurrent = connected && await waitFor(
|
||||
() => statusSeen.some(s => s.phase === 'current' && s.status === 200 && isCurrent(s.body))
|
||||
&& probeLogs.some(l => l.phase === 'current' && /probe \(load\) → current/.test(l.text)),
|
||||
15000,
|
||||
);
|
||||
// Throttle, observed through the real listeners: focus + visibilitychange
|
||||
// seconds after the load probe must not issue another status request.
|
||||
const beforeNudge = statusSeen.filter(s => s.phase === 'current').length;
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
}).catch(() => {});
|
||||
await page.waitForTimeout(3000);
|
||||
const afterNudge = statusSeen.filter(s => s.phase === 'current').length;
|
||||
const throttled = beforeNudge >= 1 && afterNudge === beforeNudge;
|
||||
const titleA = `Concert courant ${USER}`;
|
||||
const writeA = connected ? await createEvent(page, titleA) : { successToast: false, finalUrl: page.url() } as { eventUri?: string; successToast: boolean; finalUrl: string };
|
||||
const writeAOnPod = writeA.eventUri ? await podHoldsEventTitled(user, titleA) : false;
|
||||
await snap(page, 'revocation-current-after-write');
|
||||
// A visibilitychange right after the write's reload: deferred, then current.
|
||||
const visibilityProbe = connected ? await deferredProbe(page, 'visibility', /current/) : undefined;
|
||||
|
||||
// ── Phase 2: REVOKED ────────────────────────────────────────────────────
|
||||
phase = 'revoked';
|
||||
const revoke = await fetch(`${config.podProviderUrl}/.auth-agent/app-registrations/remove`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${user.token}` },
|
||||
body: JSON.stringify({ appUri }),
|
||||
});
|
||||
const revokeResult = { status: revoke.status, body: (await revoke.text()).slice(0, 300) };
|
||||
// Removal is processed by the pod and the app backend; give it a moment to settle.
|
||||
await sleep(3000);
|
||||
|
||||
// (a) FOCUS, no reload — the user revokes in the pod's tab and comes back
|
||||
// within the minute of the last probe: the deferred focus probe must read the
|
||||
// revocation and take the app off the connected view.
|
||||
const focusProbe = await deferredProbe(page, 'focus', /stale \(not-installed\)/);
|
||||
const leftByFocus = await waitFor(async () => !onApp(page) || (await pickerShown(page)), 15000);
|
||||
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 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);
|
||||
await snap(page, 'revocation-revoked-left');
|
||||
|
||||
// ── Phase 3: RECOVERED ──────────────────────────────────────────────────
|
||||
phase = 'recovered';
|
||||
const reconnected = leftAfterReloadTo === 'consent' ? await driveToApp(page) : false;
|
||||
const probeReadsCurrentAgain = reconnected && await waitFor(
|
||||
() => statusSeen.some(s => s.phase === 'recovered' && s.status === 200 && isCurrent(s.body)),
|
||||
15000,
|
||||
);
|
||||
const titleC = `Concert retabli ${USER}`;
|
||||
const writeC = reconnected ? await createEvent(page, titleC) : { successToast: false, finalUrl: page.url() } as { eventUri?: string; successToast: boolean; finalUrl: string };
|
||||
const writeCOnPod = writeC.eventUri ? await podHoldsEventTitled(user, titleC) : false;
|
||||
await snap(page, 'revocation-recovered-after-write');
|
||||
|
||||
await ctx.close();
|
||||
await browser.close();
|
||||
await sleep(1000);
|
||||
|
||||
// The deferred probe lands at the end of the minute: never before (throttle),
|
||||
// and not much after (it was deferred, not dropped). Measured between the
|
||||
// app's own log lines, so a few hundred ms of request latency either way.
|
||||
const deferredOnTime = (p?: { immediate: boolean; probed: boolean; gapFromLastProbeMs?: number }): boolean =>
|
||||
!!p && !p.immediate && p.probed && p.gapFromLastProbeMs !== undefined
|
||||
&& p.gapFromLastProbeMs >= 58_000 && p.gapFromLastProbeMs <= 75_000;
|
||||
const positive = {
|
||||
connected,
|
||||
probeRanCurrent,
|
||||
throttled,
|
||||
statusRequestsAfterNudge: afterNudge,
|
||||
write: { ...writeA, onPod: writeAOnPod },
|
||||
visibilityProbe,
|
||||
ok: connected && probeRanCurrent && throttled && !!writeA.eventUri && writeA.successToast && writeAOnPod
|
||||
&& deferredOnTime(visibilityProbe),
|
||||
};
|
||||
const negative = {
|
||||
revoke: revokeResult,
|
||||
focusProbe,
|
||||
leftByFocus,
|
||||
leftByFocusTo,
|
||||
loadProbeSawRevoked,
|
||||
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')
|
||||
&& !writeB.eventUri && !writeB.successToast && !writeBOnPod,
|
||||
};
|
||||
const recovered = {
|
||||
reconnected,
|
||||
probeReadsCurrentAgain,
|
||||
write: { ...writeC, onPod: writeCOnPod },
|
||||
ok: reconnected && 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));
|
||||
const report = {
|
||||
user: USER,
|
||||
appUri,
|
||||
positive,
|
||||
negative,
|
||||
recovered,
|
||||
statusBodies: statusSeen,
|
||||
probeLogs,
|
||||
writes,
|
||||
revokedPhase4xx: errors4xx.filter(e => e.phase === 'revoked').map(e => `${e.status} ${e.method} ${clean(e.url)}`),
|
||||
fatal4xx: fatal,
|
||||
redirects,
|
||||
hops,
|
||||
consoleErrors,
|
||||
ok: positive.ok && negative.ok && recovered.ok && fatal.length === 0,
|
||||
};
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
process.exit(report.ok ? 0 : 1);
|
||||
})().catch(err => {
|
||||
console.log(JSON.stringify({ ok: false, fatal: String(err).slice(0, 400), phase, statusSeen, probeLogs, redirects, errors4xx, consoleErrors }, null, 2));
|
||||
process.exit(1);
|
||||
});
|
||||
+127
-3
@@ -10,6 +10,10 @@
|
||||
* `clientId` and `${origin}/login` as `redirectUri`. Every session the flow
|
||||
* establishes is published to `utils/currentSession` — the module store the
|
||||
* data provider's dispatch reads, independent of where each mounts in the tree.
|
||||
* While signed in, the app-status probe (`auth/appStatusProbe`) re-checks the
|
||||
* registration on load and on focus/visibility: a revoked or stale app re-runs
|
||||
* the provider's `registerApp` (its consent redirect is the recovery) instead of
|
||||
* staying in a "connected" view whose writes cannot land.
|
||||
*
|
||||
* 2. NEXTGRAPH (suspended at the root, kept for the URL variant). The legacy
|
||||
* `ensureIdentity()` flow: the SDK mounts its own full-screen barrier and owns
|
||||
@@ -24,7 +28,7 @@
|
||||
* remains reachable.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { ensureIdentity } from '@ng-eventually/polyfill';
|
||||
import { startNgSession } from '../shared/utils/ngSession';
|
||||
import { setCurrentPrincipal } from '../shared/utils/currentPrincipal';
|
||||
@@ -39,8 +43,19 @@ import {
|
||||
safeRedirectPath,
|
||||
} from './auth/activityPodsAuth';
|
||||
import { PodProviderPicker } from './auth/PodProviderPicker';
|
||||
import { recoverRegistration, resolveStaleRegistration, type StaleReason } from './auth/appStatusProbe';
|
||||
import { useAppStatusProbe } from './auth/useAppStatusProbe';
|
||||
|
||||
type ActivityPodsAuthState = 'checking' | 'authenticated' | 'unauthenticated' | 'error';
|
||||
/**
|
||||
* `reauthorizing`: the app-status probe found the registration revoked or stale
|
||||
* and `registerApp` is sending the browser to the consent screen — the app view
|
||||
* is taken down rather than left looking connected.
|
||||
*/
|
||||
type ActivityPodsAuthState = 'checking' | 'authenticated' | 'unauthenticated' | 'reauthorizing' | 'error';
|
||||
|
||||
/** Shown on the picker when a revoked/stale registration could not be re-run. */
|
||||
const REGISTRATION_LOST_NOTICE =
|
||||
'Festipod n’est plus autorisé sur votre Pod. Reconnectez-vous pour l’autoriser à nouveau.';
|
||||
|
||||
type CallbackResult = Awaited<ReturnType<AuthProvider['handleCallback']>>;
|
||||
|
||||
@@ -86,6 +101,22 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
// ActivityPods flow state.
|
||||
const [apState, setApState] = useState<ActivityPodsAuthState>('checking');
|
||||
const [apError, setApError] = useState<string | null>(null);
|
||||
// Why the picker is back after a session existed (registration lost).
|
||||
const [apNotice, setApNotice] = useState<string | null>(null);
|
||||
// Set when the app-status probe took the connected view down: the consent
|
||||
// redirect is under way ('reauthorizing'), or the registration is gone and
|
||||
// could not be re-run ('registration-lost'). The provider's token is still
|
||||
// stored (logout is not ours to trigger here), so without this any later
|
||||
// route change — a Back, a link — would re-derive "authenticated" from that
|
||||
// token and bring back a view that can neither read nor write. Lives for the
|
||||
// page: the consent and the picker's sign-in both leave it for the pod, and a
|
||||
// reload probes afresh.
|
||||
const blockedRef = useRef<{ kind: 'reauthorizing'; reason: StaleReason } | { kind: 'registration-lost' } | null>(null);
|
||||
// One recovery at a time: the probe already serializes its own, this also
|
||||
// covers repeated clicks on the reauthorizing screen's retry (each would
|
||||
// start its own consent redirect). A `registerApp` that never settles keeps
|
||||
// it set — the same limit as a hung probe run; a reload clears it.
|
||||
const recoveringRef = useRef(false);
|
||||
|
||||
const apAuthProvider = useMemo(() => {
|
||||
if (!activityPodsEnabled) return undefined;
|
||||
@@ -188,10 +219,13 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
const session = provider.getSession();
|
||||
if (session) {
|
||||
const blocked = blockedRef.current;
|
||||
if (session && !blocked) {
|
||||
setCurrentPrincipal(session.webId);
|
||||
setCurrentSession({ webId: session.webId, token: session.token });
|
||||
if (!cancelled) setApState('authenticated');
|
||||
} else if (session && blocked?.kind === 'reauthorizing') {
|
||||
if (!cancelled) setApState('reauthorizing');
|
||||
} else {
|
||||
setCurrentSession(undefined);
|
||||
if (!cancelled) setApState('unauthenticated');
|
||||
@@ -209,6 +243,71 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
return () => { cancelled = true; };
|
||||
}, [activityPodsEnabled, apAuthProvider, demoMode, navigate, route.page]);
|
||||
|
||||
// A revoked or stale registration: re-run the provider's registration flow.
|
||||
// Its consent redirect IS the recovery; a resolved URI means the registration
|
||||
// is current after all and the app stays as it is. When it fails, what
|
||||
// happens depends on what was found: a REVOKED registration (nothing can be
|
||||
// read or written) lands on the picker with a notice; a registration that
|
||||
// only needs an upgrade stays connected and is retried by the next probe.
|
||||
const handleStaleRegistration = useCallback(async (reason: StaleReason) => {
|
||||
const provider = apAuthProvider;
|
||||
if (!provider || recoveringRef.current) return;
|
||||
recoveringRef.current = true;
|
||||
let outcome: Awaited<ReturnType<typeof recoverRegistration>>;
|
||||
try {
|
||||
outcome = await recoverRegistration({
|
||||
getWebId: () => provider.getSession()?.webId,
|
||||
registerApp: (webId) => provider.registerApp(webId),
|
||||
});
|
||||
} finally {
|
||||
recoveringRef.current = false;
|
||||
}
|
||||
const resolution = resolveStaleRegistration(reason, outcome);
|
||||
console.info(`[AppStatus] recovery (${reason}) → ${outcome.kind} → ${resolution}`);
|
||||
if (outcome.kind === 'failed') console.error('[AppStatus] registerApp failed:', outcome.detail);
|
||||
switch (resolution) {
|
||||
case 'stay-connected': {
|
||||
// From the probe this changes nothing; from the reauthorizing screen's
|
||||
// retry (a consent given meanwhile) it brings the app back — with the
|
||||
// session published again, or the data layer would serve demo data
|
||||
// behind a connected-looking app.
|
||||
const session = provider.getSession();
|
||||
if (!session) {
|
||||
blockedRef.current = null;
|
||||
setCurrentSession(undefined);
|
||||
setApState('unauthenticated');
|
||||
return;
|
||||
}
|
||||
blockedRef.current = null;
|
||||
setCurrentPrincipal(session.webId);
|
||||
setCurrentSession({ webId: session.webId, token: session.token });
|
||||
setApState('authenticated');
|
||||
return;
|
||||
}
|
||||
case 'reauthorizing':
|
||||
blockedRef.current = { kind: 'reauthorizing', reason };
|
||||
setApState('reauthorizing');
|
||||
return;
|
||||
case 'signed-out':
|
||||
blockedRef.current = null;
|
||||
setCurrentSession(undefined);
|
||||
setApState('unauthenticated');
|
||||
return;
|
||||
case 'registration-lost':
|
||||
blockedRef.current = { kind: 'registration-lost' };
|
||||
setCurrentSession(undefined);
|
||||
setApNotice(REGISTRATION_LOST_NOTICE);
|
||||
setApState('unauthenticated');
|
||||
return;
|
||||
}
|
||||
}, [apAuthProvider]);
|
||||
|
||||
useAppStatusProbe({
|
||||
provider: apAuthProvider,
|
||||
active: activityPodsEnabled && !demoMode && apState === 'authenticated',
|
||||
onStale: handleStaleRegistration,
|
||||
});
|
||||
|
||||
// Once identified, leave the welcome screen for the app home.
|
||||
useEffect(() => {
|
||||
if (apState === 'authenticated' && route.page === 'welcome') {
|
||||
@@ -265,6 +364,30 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
if (apState === 'checking') {
|
||||
return null;
|
||||
}
|
||||
if (apState === 'reauthorizing') {
|
||||
return (
|
||||
<div id="auth-reauthorizing" role="status" className="app-card" style={{ margin: '2rem 1rem' }}>
|
||||
<h1 className="app-title">Autorisation à renouveler</h1>
|
||||
<p className="app-text">
|
||||
Festipod n’est plus autorisé sur votre Pod, ou son autorisation doit être
|
||||
mise à jour. Redirection vers votre Pod pour la renouveler…
|
||||
</p>
|
||||
{/* The redirect can be interrupted (Back, a page restored from the
|
||||
browser's cache): this screen must never be a dead end. */}
|
||||
<button
|
||||
type="button"
|
||||
className="app-btn"
|
||||
onClick={() => {
|
||||
const blocked = blockedRef.current;
|
||||
void handleStaleRegistration(blocked?.kind === 'reauthorizing' ? blocked.reason : 'not-installed');
|
||||
}}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
Continuer vers mon Pod
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (apState === 'unauthenticated') {
|
||||
// Seed the post-login destination: an explicit (validated) `?redirect=`
|
||||
// wins; otherwise a deep link the user opened lands them back on that page
|
||||
@@ -280,6 +403,7 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
<PodProviderPicker
|
||||
authProvider={apAuthProvider!}
|
||||
redirect={loginRedirect}
|
||||
notice={apNotice ?? undefined}
|
||||
onDemo={() => {
|
||||
setDemoMode(true);
|
||||
navigate('/home');
|
||||
|
||||
@@ -11,6 +11,8 @@ interface PodProviderPickerProps {
|
||||
* so the picker never reads the raw URL itself.
|
||||
*/
|
||||
redirect: string;
|
||||
/** Why the user is back on the picker (e.g. the app's registration was revoked). */
|
||||
notice?: string;
|
||||
onDemo?: () => void;
|
||||
}
|
||||
|
||||
@@ -21,7 +23,7 @@ interface PodProviderPickerProps {
|
||||
* offered as a one-click option. The user can also type any Solid Pod provider
|
||||
* URL. A "continue offline" button keeps the local demo fallback reachable.
|
||||
*/
|
||||
export function PodProviderPicker({ authProvider, redirect, onDemo }: PodProviderPickerProps) {
|
||||
export function PodProviderPicker({ authProvider, redirect, notice, onDemo }: PodProviderPickerProps) {
|
||||
const { podProviderUrl } = resolveActivityPodsAuthConfig(window.location.origin);
|
||||
const [customUrl, setCustomUrl] = useState('');
|
||||
|
||||
@@ -68,6 +70,17 @@ export function PodProviderPicker({ authProvider, redirect, onDemo }: PodProvide
|
||||
Connectez-vous avec votre Pod pour découvrir et créer des points de rencontre.
|
||||
</Text>
|
||||
|
||||
{notice && (
|
||||
<p
|
||||
role="status"
|
||||
data-testid="auth-picker-notice"
|
||||
className="app-text"
|
||||
style={{ textAlign: 'center', fontSize: 14, marginBottom: 24, color: '#b45309' }}
|
||||
>
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{defaultProviderHost && (
|
||||
<Button
|
||||
variant="primary"
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
MIN_PROBE_INTERVAL_MS,
|
||||
classifyAppStatus,
|
||||
createAppStatusProbe,
|
||||
recoverRegistration,
|
||||
resolveStaleRegistration,
|
||||
type AppStatusProbeDeps,
|
||||
type StaleReason,
|
||||
} from './appStatusProbe';
|
||||
|
||||
// Bodies as the local pod (v2.3.12) returns them from /.well-known/app-status to
|
||||
// the app's session token (recorded by scripts/e2e-revocation.ts): current, and
|
||||
// after the registration was removed. UPGRADE is not observed — it is the shape
|
||||
// the contract's re-baseline names (`upgradeNeeded: true`).
|
||||
const CURRENT = { onlineBackend: true, installed: true, upgradeNeeded: false, webhookChannels: [] };
|
||||
const REVOKED = { onlineBackend: true, installed: false, upgradeNeeded: false };
|
||||
const UPGRADE = { onlineBackend: true, installed: true, upgradeNeeded: true, webhookChannels: [] };
|
||||
|
||||
/** A probe over a scripted status source, a manual clock, manual timers and a captured log. */
|
||||
function harness(statuses: Array<unknown | (() => Promise<unknown>)>, opts: { session?: () => boolean } = {}) {
|
||||
let clock = 1_000_000;
|
||||
let nextHandle = 1;
|
||||
const timers = new Map<number, { fn: () => void; due: number }>();
|
||||
const calls: number[] = [];
|
||||
const stale: StaleReason[] = [];
|
||||
const logs: string[] = [];
|
||||
const queue = [...statuses];
|
||||
const deps: AppStatusProbeDeps = {
|
||||
hasSession: opts.session ?? (() => true),
|
||||
getAppStatus: () => {
|
||||
calls.push(clock);
|
||||
const next = queue.shift();
|
||||
return typeof next === 'function' ? (next as () => Promise<unknown>)() : Promise.resolve(next);
|
||||
},
|
||||
onStale: (reason) => { stale.push(reason); },
|
||||
now: () => clock,
|
||||
log: (m) => { logs.push(m); },
|
||||
setTimer: (fn, ms) => { const h = nextHandle++; timers.set(h, { fn, due: clock + ms }); return h; },
|
||||
clearTimer: (h) => { timers.delete(h as number); },
|
||||
};
|
||||
return {
|
||||
probe: createAppStatusProbe(deps),
|
||||
calls,
|
||||
stale,
|
||||
logs,
|
||||
timers,
|
||||
/** Move the clock and fire every timer that came due, as the event loop would. */
|
||||
advance: async (ms: number) => {
|
||||
clock += ms;
|
||||
for (const [h, t] of [...timers]) {
|
||||
if (t.due <= clock) { timers.delete(h); t.fn(); }
|
||||
}
|
||||
// let the fired runs settle
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('classifyAppStatus', () => {
|
||||
test('installed and no upgrade is current', () => {
|
||||
expect(classifyAppStatus(CURRENT)).toEqual({ kind: 'current' });
|
||||
});
|
||||
|
||||
test('not installed (what a revocation leaves) is stale even though upgradeNeeded is false', () => {
|
||||
expect(classifyAppStatus(REVOKED)).toEqual({ kind: 'stale', reason: 'not-installed' });
|
||||
});
|
||||
|
||||
test('upgradeNeeded is stale', () => {
|
||||
expect(classifyAppStatus(UPGRADE)).toEqual({ kind: 'stale', reason: 'upgrade-needed' });
|
||||
});
|
||||
|
||||
test('not installed wins over upgradeNeeded', () => {
|
||||
expect(classifyAppStatus({ installed: false, upgradeNeeded: true })).toEqual({ kind: 'stale', reason: 'not-installed' });
|
||||
});
|
||||
|
||||
test('a body that decides nothing is undecided, not stale', () => {
|
||||
expect(classifyAppStatus({ upgradeNeeded: false }).kind).toBe('undecided');
|
||||
expect(classifyAppStatus({ installed: 'yes', upgradeNeeded: false }).kind).toBe('undecided');
|
||||
expect(classifyAppStatus(null).kind).toBe('undecided');
|
||||
expect(classifyAppStatus('ok').kind).toBe('undecided');
|
||||
expect(classifyAppStatus([CURRENT]).kind).toBe('undecided');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAppStatusProbe — throttle', () => {
|
||||
test('the interval is never below a minute', () => {
|
||||
expect(MIN_PROBE_INTERVAL_MS).toBeGreaterThanOrEqual(60_000);
|
||||
});
|
||||
|
||||
test('a trigger less than a minute after the previous run makes no request now', async () => {
|
||||
const h = harness([CURRENT, CURRENT]);
|
||||
expect(await h.probe.run('load')).toEqual({ kind: 'current' });
|
||||
await h.advance(MIN_PROBE_INTERVAL_MS - 1);
|
||||
expect(await h.probe.run('focus')).toEqual({ kind: 'skipped', why: 'throttled', deferredMs: 1 });
|
||||
expect(h.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('a throttled trigger is deferred to the end of the minute, not dropped', async () => {
|
||||
// The user revokes in the pod's tab and comes back 40 s after the load probe.
|
||||
const h = harness([CURRENT, REVOKED]);
|
||||
await h.probe.run('load');
|
||||
await h.advance(40_000);
|
||||
expect(await h.probe.run('focus')).toEqual({ kind: 'skipped', why: 'throttled', deferredMs: 20_000 });
|
||||
await h.advance(19_999);
|
||||
expect(h.calls).toHaveLength(1);
|
||||
await h.advance(1);
|
||||
expect(h.calls).toHaveLength(2);
|
||||
expect(h.stale).toEqual(['not-installed']);
|
||||
expect(h.logs.some((l) => l.includes('probe (focus) → stale (not-installed)'))).toBe(true);
|
||||
});
|
||||
|
||||
test('at most one deferred run is pending, whatever the number of triggers', async () => {
|
||||
const h = harness([CURRENT, CURRENT, CURRENT]);
|
||||
await h.probe.run('load');
|
||||
await h.advance(10_000);
|
||||
await h.probe.run('focus');
|
||||
expect(await h.probe.run('visibility')).toEqual({ kind: 'skipped', why: 'throttled' });
|
||||
await h.probe.run('focus');
|
||||
expect(h.timers.size).toBe(1);
|
||||
await h.advance(MIN_PROBE_INTERVAL_MS);
|
||||
expect(h.calls).toHaveLength(2);
|
||||
expect(h.timers.size).toBe(0);
|
||||
});
|
||||
|
||||
test('two runs never start less than a minute apart, deferred ones included', async () => {
|
||||
const h = harness([CURRENT, CURRENT, CURRENT]);
|
||||
await h.probe.run('load');
|
||||
await h.advance(30_000);
|
||||
await h.probe.run('focus'); // deferred to +60 s
|
||||
await h.advance(30_000); // deferred run starts here
|
||||
await h.advance(1_000);
|
||||
await h.probe.run('visibility'); // deferred to +120 s
|
||||
await h.advance(58_999);
|
||||
expect(h.calls).toHaveLength(2);
|
||||
await h.advance(1);
|
||||
expect(h.calls).toHaveLength(3);
|
||||
const gaps = h.calls.slice(1).map((t, i) => t - h.calls[i]!);
|
||||
expect(gaps.every((g) => g >= MIN_PROBE_INTERVAL_MS)).toBe(true);
|
||||
});
|
||||
|
||||
test('cancelPending drops a deferred trigger — nothing probes once the view is gone', async () => {
|
||||
const h = harness([CURRENT, CURRENT]);
|
||||
await h.probe.run('load');
|
||||
await h.advance(5_000);
|
||||
await h.probe.run('focus');
|
||||
h.probe.cancelPending();
|
||||
expect(h.timers.size).toBe(0);
|
||||
await h.advance(MIN_PROBE_INTERVAL_MS * 2);
|
||||
expect(h.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('a deferred run whose session has ended requests nothing', async () => {
|
||||
let session = true;
|
||||
const h = harness([CURRENT, CURRENT], { session: () => session });
|
||||
await h.probe.run('load');
|
||||
await h.advance(5_000);
|
||||
await h.probe.run('focus');
|
||||
session = false;
|
||||
await h.advance(MIN_PROBE_INTERVAL_MS);
|
||||
expect(h.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('a run a full minute later probes again', async () => {
|
||||
const h = harness([CURRENT, REVOKED]);
|
||||
await h.probe.run('load');
|
||||
await h.advance(MIN_PROBE_INTERVAL_MS);
|
||||
expect(await h.probe.run('focus')).toEqual({ kind: 'stale', reason: 'not-installed' });
|
||||
expect(h.calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('a run while one is in flight is skipped', async () => {
|
||||
let release: (v: unknown) => void = () => {};
|
||||
const h = harness([() => new Promise((r) => { release = r; })]);
|
||||
const first = h.probe.run('load');
|
||||
await h.advance(MIN_PROBE_INTERVAL_MS * 2);
|
||||
expect(await h.probe.run('focus')).toEqual({ kind: 'skipped', why: 'in-flight' });
|
||||
release(CURRENT);
|
||||
expect(await first).toEqual({ kind: 'current' });
|
||||
expect(h.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('without a session nothing is requested and the minute is not consumed', async () => {
|
||||
const h = harness([CURRENT], { session: () => false });
|
||||
expect(await h.probe.run('load')).toEqual({ kind: 'skipped', why: 'no-session' });
|
||||
expect(h.calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAppStatusProbe — fails open', () => {
|
||||
test('a network error is logged and nothing else happens', async () => {
|
||||
const h = harness([() => Promise.reject(new TypeError('Failed to fetch'))]);
|
||||
const outcome = await h.probe.run('load');
|
||||
expect(outcome.kind).toBe('failed-open');
|
||||
expect(h.stale).toEqual([]);
|
||||
expect(h.logs.some((l) => l.includes('failing open') && l.includes('Failed to fetch'))).toBe(true);
|
||||
});
|
||||
|
||||
test('a 5xx (the provider throws with status attached) fails open', async () => {
|
||||
const err = Object.assign(new Error('Service Unavailable'), { status: 503, body: '' });
|
||||
const h = harness([() => Promise.reject(err)]);
|
||||
const outcome = await h.probe.run('focus');
|
||||
expect(outcome).toEqual({ kind: 'failed-open', detail: 'HTTP 503: Service Unavailable' });
|
||||
expect(h.stale).toEqual([]);
|
||||
});
|
||||
|
||||
test('an undecidable body does not trigger recovery', async () => {
|
||||
const h = harness([{ upgradeNeeded: false }]);
|
||||
expect((await h.probe.run('load')).kind).toBe('undecided');
|
||||
expect(h.stale).toEqual([]);
|
||||
});
|
||||
|
||||
test('a failed run still counts for the throttle — a failing pod is not hammered', async () => {
|
||||
const h = harness([() => Promise.reject(new TypeError('Failed to fetch')), CURRENT]);
|
||||
await h.probe.run('load');
|
||||
await h.advance(1_000);
|
||||
expect(await h.probe.run('focus')).toEqual({ kind: 'skipped', why: 'throttled', deferredMs: MIN_PROBE_INTERVAL_MS - 1_000 });
|
||||
expect(h.calls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAppStatusProbe — stale readings', () => {
|
||||
test('a revoked registration calls onStale once with not-installed', async () => {
|
||||
const h = harness([REVOKED]);
|
||||
await h.probe.run('load');
|
||||
expect(h.stale).toEqual(['not-installed']);
|
||||
});
|
||||
|
||||
test('an upgrade-needed registration calls onStale with upgrade-needed', async () => {
|
||||
const h = harness([UPGRADE]);
|
||||
await h.probe.run('visibility');
|
||||
expect(h.stale).toEqual(['upgrade-needed']);
|
||||
});
|
||||
|
||||
test('a current registration does not call onStale', async () => {
|
||||
const h = harness([CURRENT]);
|
||||
await h.probe.run('load');
|
||||
expect(h.stale).toEqual([]);
|
||||
});
|
||||
|
||||
test('a run waits for onStale, so no second probe starts during the recovery', async () => {
|
||||
let finishRecovery: () => void = () => {};
|
||||
let clock = 0;
|
||||
const calls: number[] = [];
|
||||
const probe = createAppStatusProbe({
|
||||
hasSession: () => true,
|
||||
getAppStatus: () => { calls.push(clock); return Promise.resolve(REVOKED); },
|
||||
onStale: () => new Promise<void>((r) => { finishRecovery = r; }),
|
||||
now: () => clock,
|
||||
log: () => {},
|
||||
});
|
||||
const first = probe.run('load');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
clock += MIN_PROBE_INTERVAL_MS * 2;
|
||||
expect(await probe.run('focus')).toEqual({ kind: 'skipped', why: 'in-flight' });
|
||||
finishRecovery();
|
||||
await first;
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recoverRegistration', () => {
|
||||
test('registerApp resolving undefined means the browser is leaving for consent', async () => {
|
||||
const outcome = await recoverRegistration({ getWebId: () => 'http://pod/alice', registerApp: async () => undefined });
|
||||
expect(outcome).toEqual({ kind: 'redirecting' });
|
||||
});
|
||||
|
||||
test('registerApp resolving a URI means the registration is current after all', async () => {
|
||||
const outcome = await recoverRegistration({ getWebId: () => 'http://pod/alice', registerApp: async () => 'http://pod/alice/reg/1' });
|
||||
expect(outcome).toEqual({ kind: 'registered', registration: 'http://pod/alice/reg/1' });
|
||||
});
|
||||
|
||||
test('registerApp throwing is a failed recovery, reported with its status', async () => {
|
||||
const err = Object.assign(new Error('Forbidden'), { status: 403 });
|
||||
const outcome = await recoverRegistration({ getWebId: () => 'http://pod/alice', registerApp: async () => { throw err; } });
|
||||
expect(outcome).toEqual({ kind: 'failed', detail: 'HTTP 403: Forbidden' });
|
||||
});
|
||||
|
||||
test('no session: registerApp is not called', async () => {
|
||||
let called = false;
|
||||
const outcome = await recoverRegistration({ getWebId: () => undefined, registerApp: async () => { called = true; return undefined; } });
|
||||
expect(outcome).toEqual({ kind: 'no-session' });
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
test('registerApp receives the session webId', async () => {
|
||||
let seen: string | undefined;
|
||||
await recoverRegistration({ getWebId: () => 'http://pod/alice', registerApp: async (w) => { seen = w; return undefined; } });
|
||||
expect(seen).toBe('http://pod/alice');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveStaleRegistration', () => {
|
||||
const failed = { kind: 'failed', detail: 'HTTP 502: Bad Gateway' } as const;
|
||||
|
||||
test('registered after all: the app stays connected, whatever was read', () => {
|
||||
expect(resolveStaleRegistration('not-installed', { kind: 'registered', registration: 'r' })).toBe('stay-connected');
|
||||
expect(resolveStaleRegistration('upgrade-needed', { kind: 'registered', registration: 'r' })).toBe('stay-connected');
|
||||
});
|
||||
|
||||
test('consent redirect under way: the connected view is taken down', () => {
|
||||
expect(resolveStaleRegistration('not-installed', { kind: 'redirecting' })).toBe('reauthorizing');
|
||||
expect(resolveStaleRegistration('upgrade-needed', { kind: 'redirecting' })).toBe('reauthorizing');
|
||||
});
|
||||
|
||||
test('session ended meanwhile: signed out, no registration-lost claim', () => {
|
||||
expect(resolveStaleRegistration('not-installed', { kind: 'no-session' })).toBe('signed-out');
|
||||
expect(resolveStaleRegistration('upgrade-needed', { kind: 'no-session' })).toBe('signed-out');
|
||||
});
|
||||
|
||||
test('a revoked registration whose recovery failed is lost — never left connected', () => {
|
||||
expect(resolveStaleRegistration('not-installed', failed)).toBe('registration-lost');
|
||||
});
|
||||
|
||||
test('an upgrade whose recovery failed fails open — current grants still work', () => {
|
||||
expect(resolveStaleRegistration('upgrade-needed', failed)).toBe('stay-connected');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* App-status probe — re-checks, while a session exists, that the pod still
|
||||
* holds a current registration for Festipod.
|
||||
*
|
||||
* WHY. Without it, a user who revokes the app on their pod (or whose grants go
|
||||
* stale after an app upgrade) keeps seeing a "connected" Festipod whose reads
|
||||
* come back empty and whose writes cannot land. Nothing re-checked the
|
||||
* registration after the sign-in flow.
|
||||
*
|
||||
* THE MECHANISM IS THE PROVIDER'S (pinned contract, re-baseline 2026-10-12):
|
||||
* `getAppStatus()` is a plain GET of the pod's `/.well-known/app-status` with
|
||||
* the session token, and `registerApp(webId)` is the recovery — it resolves the
|
||||
* AppRegistration URI when the registration is present and current, and
|
||||
* otherwise sends the browser to the consent screen. The provider's own drive
|
||||
* is disabled in Festipod (`appStatusCheckInterval: false`, usage declaration
|
||||
* friction 1: it leaks a timer and a listener per construction), so this module
|
||||
* is the app's own schedule for it.
|
||||
*
|
||||
* WHAT THE BODY SAYS (observed on the local pod v2.3.12, 2026-10-12, with the
|
||||
* app's own session token — `scripts/e2e-revocation.ts` records it on each run):
|
||||
* a current registration answers
|
||||
* `{ onlineBackend: true, installed: true, upgradeNeeded: false, webhookChannels: [...] }`;
|
||||
* after `POST {pod}/.auth-agent/app-registrations/remove` the same request
|
||||
* answers `{ onlineBackend: true, installed: false, upgradeNeeded: false }`.
|
||||
* A revocation therefore leaves `upgradeNeeded` FALSE — the provider's own
|
||||
* check, which acts on `upgradeNeeded` only, would miss it. The probe treats
|
||||
* `installed === false` as stale too.
|
||||
*
|
||||
* SCHEDULE. Event-driven, never a polling loop (the app has none, by
|
||||
* doctrine): on app load once signed in, and when the window regains focus or
|
||||
* becomes visible. Throttled — no two runs start less than a minute apart, and
|
||||
* only one runs at a time. A trigger that lands inside the minute is NOT
|
||||
* dropped: it is deferred to the end of the minute (one pending run at most,
|
||||
* cancelled when the signed-in view goes away). Dropping it would miss exactly
|
||||
* the case this exists for — a user who revokes in the pod's tab and comes
|
||||
* straight back, then stays. A trigger that finds a run in flight is absorbed
|
||||
* by that run, which is reading the status already; the one gap is a run that
|
||||
* hangs past the minute (then fails open), after which the next focus or
|
||||
* visibility probes again.
|
||||
*
|
||||
* FAILS OPEN. A probe that throws (network error, 5xx, anything) is logged and
|
||||
* the app keeps going, mirroring the provider's own upgrade check: an
|
||||
* unreachable status endpoint is not evidence of a revocation, and turning
|
||||
* every pod hiccup into a sign-out would be worse than the hole it closes.
|
||||
*/
|
||||
|
||||
/** Minimum time between two probe runs. Never below a minute. */
|
||||
export const MIN_PROBE_INTERVAL_MS = 60_000;
|
||||
|
||||
/** What woke the probe up. */
|
||||
export type ProbeTrigger = 'load' | 'focus' | 'visibility';
|
||||
|
||||
/** Why a registration is not usable as it stands. */
|
||||
export type StaleReason = 'not-installed' | 'upgrade-needed';
|
||||
|
||||
/** The probe's reading of one status body. */
|
||||
export type AppStatusVerdict =
|
||||
| { kind: 'current' }
|
||||
| { kind: 'stale'; reason: StaleReason }
|
||||
/** The body did not say anything decidable — treated as current (fail open). */
|
||||
| { kind: 'undecided'; detail: string };
|
||||
|
||||
/** The outcome of one `run()` call. */
|
||||
export type ProbeOutcome =
|
||||
/** `deferredMs`: when a throttled trigger will run (undefined: one is already pending). */
|
||||
| { kind: 'skipped'; why: 'throttled'; deferredMs?: number }
|
||||
| { kind: 'skipped'; why: 'in-flight' | 'no-session' }
|
||||
| { kind: 'failed-open'; detail: string }
|
||||
| AppStatusVerdict;
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | undefined {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a status body. `installed === false` (no registration for this app —
|
||||
* what a revocation leaves) and `upgradeNeeded === true` (registered, grants
|
||||
* out of date) are stale; a body that says installed and no upgrade is current.
|
||||
* Anything else — a missing or non-boolean member, a non-object — is undecided,
|
||||
* and undecided fails open like an error does.
|
||||
*/
|
||||
export function classifyAppStatus(body: unknown): AppStatusVerdict {
|
||||
const status = asObject(body);
|
||||
if (!status) return { kind: 'undecided', detail: `status body is not an object: ${JSON.stringify(body)}` };
|
||||
if (status['installed'] === false) return { kind: 'stale', reason: 'not-installed' };
|
||||
if (status['upgradeNeeded'] === true) return { kind: 'stale', reason: 'upgrade-needed' };
|
||||
if (status['installed'] === true && status['upgradeNeeded'] === false) return { kind: 'current' };
|
||||
return { kind: 'undecided', detail: `status body not decidable: ${JSON.stringify(status)}` };
|
||||
}
|
||||
|
||||
/** Best-effort description of a thrown probe failure (the provider attaches `status`). */
|
||||
function describeFailure(err: unknown): string {
|
||||
const status = asObject(err)?.['status'];
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return typeof status === 'number' ? `HTTP ${status}: ${message}` : message;
|
||||
}
|
||||
|
||||
export interface AppStatusProbeDeps {
|
||||
/** Is there a session to probe for? No session → nothing to check. */
|
||||
hasSession: () => boolean;
|
||||
/** The provider's `getAppStatus()`. */
|
||||
getAppStatus: () => Promise<unknown>;
|
||||
/** Called once per stale reading; the probe waits for it before the next run can start. */
|
||||
onStale: (reason: StaleReason) => Promise<void> | void;
|
||||
/** Clock, injectable for tests. Defaults to `Date.now`. */
|
||||
now?: () => number;
|
||||
/** Logger, injectable for tests. Defaults to `console.info`. */
|
||||
log?: (message: string) => void;
|
||||
/** One-shot timer for a deferred trigger, injectable for tests. Defaults to `setTimeout`. */
|
||||
setTimer?: (fn: () => void, ms: number) => unknown;
|
||||
/** Cancels what `setTimer` returned. Defaults to `clearTimeout`. */
|
||||
clearTimer?: (handle: unknown) => void;
|
||||
}
|
||||
|
||||
export interface AppStatusProbe {
|
||||
run(trigger: ProbeTrigger): Promise<ProbeOutcome>;
|
||||
/** Drop a deferred trigger, if any — the signed-in view is going away. */
|
||||
cancelPending(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a throttled probe. Hold ONE per page session (the hook keeps it in a
|
||||
* ref): the throttle lives in the instance, so a remount that rebuilt it would
|
||||
* reset the minute.
|
||||
*/
|
||||
export function createAppStatusProbe(deps: AppStatusProbeDeps): AppStatusProbe {
|
||||
const now = deps.now ?? Date.now;
|
||||
const log = deps.log ?? ((message: string) => console.info(message));
|
||||
const setTimer = deps.setTimer ?? ((fn: () => void, ms: number) => setTimeout(fn, ms));
|
||||
const clearTimer = deps.clearTimer ?? ((handle: unknown) => clearTimeout(handle as ReturnType<typeof setTimeout>));
|
||||
let lastStart: number | undefined;
|
||||
let inFlight = false;
|
||||
let pending: unknown;
|
||||
|
||||
const probe: AppStatusProbe = {
|
||||
cancelPending() {
|
||||
if (pending !== undefined) clearTimer(pending);
|
||||
pending = undefined;
|
||||
},
|
||||
async run(trigger) {
|
||||
if (inFlight) return { kind: 'skipped', why: 'in-flight' };
|
||||
if (!deps.hasSession()) return { kind: 'skipped', why: 'no-session' };
|
||||
const at = now();
|
||||
if (lastStart !== undefined && at - lastStart < MIN_PROBE_INTERVAL_MS) {
|
||||
if (pending !== undefined) return { kind: 'skipped', why: 'throttled' };
|
||||
const deferredMs = lastStart + MIN_PROBE_INTERVAL_MS - at;
|
||||
pending = setTimer(() => {
|
||||
pending = undefined;
|
||||
void probe.run(trigger);
|
||||
}, deferredMs);
|
||||
return { kind: 'skipped', why: 'throttled', deferredMs };
|
||||
}
|
||||
lastStart = at;
|
||||
inFlight = true;
|
||||
try {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await deps.getAppStatus();
|
||||
} catch (err) {
|
||||
const detail = describeFailure(err);
|
||||
log(`[AppStatus] probe (${trigger}) failed, failing open: ${detail}`);
|
||||
return { kind: 'failed-open', detail };
|
||||
}
|
||||
const verdict = classifyAppStatus(body);
|
||||
log(`[AppStatus] probe (${trigger}) → ${verdict.kind}${verdict.kind === 'stale' ? ` (${verdict.reason})` : ''} ${JSON.stringify(body)}`);
|
||||
if (verdict.kind === 'stale') await deps.onStale(verdict.reason);
|
||||
return verdict;
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
return probe;
|
||||
}
|
||||
|
||||
/** What re-running the registration flow led to. */
|
||||
export type RecoveryOutcome =
|
||||
/** `registerApp` resolved `undefined`: the browser is leaving for the consent screen. */
|
||||
| { kind: 'redirecting' }
|
||||
/** `registerApp` found a current registration after all — nothing to recover. */
|
||||
| { kind: 'registered'; registration: string }
|
||||
/** No session left to register for. */
|
||||
| { kind: 'no-session' }
|
||||
/** `registerApp` threw: no recovery from here. */
|
||||
| { kind: 'failed'; detail: string };
|
||||
|
||||
export interface RecoveryDeps {
|
||||
getWebId: () => string | undefined;
|
||||
registerApp: (webId: string) => Promise<string | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-run the registration flow for a stale reading — the provider's
|
||||
* `registerApp`, whose consent redirect IS the recovery path. A resolved URI
|
||||
* means the registration is present and current (the status endpoint and the
|
||||
* registration lookup disagreed, e.g. a reading taken just before a consent
|
||||
* settled): the provider's answer wins and the app stays connected.
|
||||
*/
|
||||
export async function recoverRegistration(deps: RecoveryDeps): Promise<RecoveryOutcome> {
|
||||
const webId = deps.getWebId();
|
||||
if (!webId) return { kind: 'no-session' };
|
||||
try {
|
||||
const registration = await deps.registerApp(webId);
|
||||
return registration ? { kind: 'registered', registration } : { kind: 'redirecting' };
|
||||
} catch (err) {
|
||||
return { kind: 'failed', detail: describeFailure(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/** What the app does with a stale reading once the recovery has run. */
|
||||
export type StaleResolution =
|
||||
/** Nothing to do: the registration is current after all, or a failed upgrade recovery fails open. */
|
||||
| 'stay-connected'
|
||||
/** The browser is leaving for the consent screen: take the connected view down meanwhile. */
|
||||
| 'reauthorizing'
|
||||
/** The session ended meanwhile: back to the picker, nothing to explain. */
|
||||
| 'signed-out'
|
||||
/** Registration gone and not re-runnable: the picker, with why, and no way back into the dead view. */
|
||||
| 'registration-lost';
|
||||
|
||||
/**
|
||||
* Decide from what was found (`reason`) and what the recovery led to. The split
|
||||
* on a failed recovery is deliberate: a REVOKED registration (`not-installed`)
|
||||
* can neither read nor write, so staying connected would be the silent breakage
|
||||
* this exists to end; a registration that only needs an UPGRADE still works on
|
||||
* its current grants, so a recovery that could not run fails open like a probe
|
||||
* that could not read, and the next probe retries it.
|
||||
*/
|
||||
export function resolveStaleRegistration(reason: StaleReason, outcome: RecoveryOutcome): StaleResolution {
|
||||
switch (outcome.kind) {
|
||||
case 'registered': return 'stay-connected';
|
||||
case 'redirecting': return 'reauthorizing';
|
||||
case 'no-session': return 'signed-out';
|
||||
case 'failed': return reason === 'upgrade-needed' ? 'stay-connected' : 'registration-lost';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { AuthProvider } from '@activitypods/refine-providers';
|
||||
import { createAppStatusProbe, type AppStatusProbe, type StaleReason } from './appStatusProbe';
|
||||
|
||||
interface UseAppStatusProbeOptions {
|
||||
/** The auth provider to probe through; `undefined` disables the probe. */
|
||||
provider: AuthProvider | undefined;
|
||||
/** Probe only while the app shows a signed-in view. */
|
||||
active: boolean;
|
||||
/** What to do on a stale reading (re-run the registration flow). */
|
||||
onStale: (reason: StaleReason) => Promise<void> | void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the app-status probe while signed in: one run when the signed-in view
|
||||
* mounts, then one per window `focus` / `visibilitychange` to visible, all
|
||||
* subject to the probe's one-minute throttle (a trigger inside the minute is
|
||||
* deferred to its end, not dropped).
|
||||
*
|
||||
* The listeners are attached in an effect and removed in its cleanup, which
|
||||
* also cancels a deferred trigger: nothing probes once the signed-in view is
|
||||
* gone, and a remount (StrictMode's double effect, a backend switch) never
|
||||
* accumulates listeners or timers — the very leak that made the provider's
|
||||
* built-in drive unusable here (usage declaration, friction 1). The probe
|
||||
* instance — and with it the throttle — outlives those remounts in a ref, for
|
||||
* the life of the page.
|
||||
*/
|
||||
export function useAppStatusProbe({ provider, active, onStale }: UseAppStatusProbeOptions): void {
|
||||
const onStaleRef = useRef(onStale);
|
||||
onStaleRef.current = onStale;
|
||||
const probeRef = useRef<{ provider: AuthProvider; probe: AppStatusProbe } | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !provider) return;
|
||||
if (probeRef.current?.provider !== provider) {
|
||||
probeRef.current = {
|
||||
provider,
|
||||
probe: createAppStatusProbe({
|
||||
hasSession: () => provider.getSession() !== undefined,
|
||||
getAppStatus: () => provider.getAppStatus(),
|
||||
onStale: (reason) => onStaleRef.current(reason),
|
||||
}),
|
||||
};
|
||||
}
|
||||
const { probe } = probeRef.current;
|
||||
|
||||
void probe.run('load');
|
||||
const onFocus = () => { void probe.run('focus'); };
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'visible') void probe.run('visibility');
|
||||
};
|
||||
window.addEventListener('focus', onFocus);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
return () => {
|
||||
window.removeEventListener('focus', onFocus);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
probe.cancelPending();
|
||||
};
|
||||
}, [active, provider]);
|
||||
}
|
||||
@@ -67,10 +67,9 @@
|
||||
* together, and the data/live providers take the auth provider as a parameter. Its
|
||||
* `appStatusCheckInterval` is disabled deliberately: construction installs a
|
||||
* `setInterval` and a `visibilitychange` listener with no teardown (usage
|
||||
* declaration, friction 1). Driving `getAppStatus()` on Festipod's own schedule is
|
||||
* the planned replacement, but it is NOT wired yet: the contract documents the
|
||||
* method's name only, not the shape of the `AppStatus` result, so the follow-up is
|
||||
* parked until that surface is pinned (handoff, follow-ups). The `clientId` names
|
||||
* declaration, friction 1). Festipod drives `getAppStatus()` on its own schedule
|
||||
* instead — the app-status probe beside the auth flow (`src/app/auth/appStatusProbe.ts`),
|
||||
* against the shape pinned in the contract's 2026-10-12 re-baseline. The `clientId` names
|
||||
* the app; the app description document it points to
|
||||
* is served by Festipod's own backend, and the values resolve from the runtime
|
||||
* config — the SAME source the AuthGate flow uses, so both halves agree.
|
||||
|
||||
Reference in New Issue
Block a user