mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Merge wave A — the invitation link lands on registration, never on login
The invite landing is public in the client shell as well as the proxy, the edge answers the redirect itself so no shell mounts, and registration refuses to run over a live session instead of silently swapping the caller's cookie for the new account's.
This commit is contained in:
commit
30339d8f7d
22 files changed
+868
-27
No files matched your search
+13
-13
@@ -29943,19 +29943,19 @@ components:
|
||||
carry no code at all. ai_provider — ai_provider.invalid, ai_provider.no_fields; allergy —
|
||||
allergy.invalid; analytics — analytics.invalid_query; anamnesis — anamnesis.fact.conflict,
|
||||
anamnesis.fact.currentExists, anamnesis.fact.invalidValue; auth — auth.admin.required,
|
||||
auth.mfa.code_invalid, auth.missing, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked,
|
||||
auth.scope.insufficient, auth.stepup.required, auth.token.expired, auth.token.invalid; backup —
|
||||
backup.payload.undecryptable, backup.section.missing; coach — coach.conversation.invalidTitle,
|
||||
coach.conversation.notFound, coach.fenced.attachmentConflict, coach.fenced.attachmentLimit,
|
||||
coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired,
|
||||
coach.fenced.attachmentUnavailable, coach.fenced.invalid, coach.request.invalid; coach-prefs —
|
||||
coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; consent — consent.ai.required; cycle —
|
||||
cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range,
|
||||
cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled,
|
||||
cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs —
|
||||
cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json;
|
||||
documents — documents.chat.conversationNotFound, documents.chat.invalid,
|
||||
documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed,
|
||||
auth.already_authenticated, auth.mfa.code_invalid, auth.missing, auth.refresh.invalid,
|
||||
auth.refresh.reuse, auth.refresh.revoked, auth.scope.insufficient, auth.stepup.required,
|
||||
auth.token.expired, auth.token.invalid; backup — backup.payload.undecryptable, backup.section.missing;
|
||||
coach — coach.conversation.invalidTitle, coach.conversation.notFound, coach.fenced.attachmentConflict,
|
||||
coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed,
|
||||
coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid,
|
||||
coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape;
|
||||
consent — consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid,
|
||||
cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid,
|
||||
cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit;
|
||||
cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach —
|
||||
disable-coach.body.invalid_json; documents — documents.chat.conversationNotFound,
|
||||
documents.chat.invalid, documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed,
|
||||
documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound,
|
||||
documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound,
|
||||
documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge,
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* The journey that would have caught the invite defect.
|
||||
*
|
||||
* Every layer of the invite chain had unit coverage and the chain was broken
|
||||
* anyway: the proxy test proved the edge admits `/invite/<token>`, the landing
|
||||
* page test proved it redirects onto `/auth/register?invite=…`, and neither
|
||||
* could see that in the real runtime the redirect arrives in an RSC flight
|
||||
* payload AFTER the shell has flushed — so the shell mounted on `/invite/…`,
|
||||
* read it as a protected route and raced its own `router.replace(
|
||||
* "/auth/login")` against it. The operator scanned his own QR in a private
|
||||
* window and landed on the sign-in page holding a one-time invitation the
|
||||
* sign-in page cannot use.
|
||||
*
|
||||
* So this spec is deliberately end to end and deliberately about the SEAM:
|
||||
* mint a real invitation in the admin UI, open the real URL in a genuinely
|
||||
* cookie-less context, and assert both where the visitor ends up and where
|
||||
* they never went.
|
||||
*
|
||||
* The second case is the other half of the same defect: the same link opened
|
||||
* in the admin's own window used to render the bare form, and submitting it
|
||||
* replaced his session cookie with the new account's — signing him out of his
|
||||
* admin account and into a stranger's onboarding.
|
||||
*
|
||||
* Neither case completes a signup: that would add a user to the shared e2e
|
||||
* database and spend the 5-per-15-minutes registration bucket every other
|
||||
* spec shares. What the signup does with the token is pinned in
|
||||
* `src/app/api/auth/register/__tests__/route.test.ts`.
|
||||
*/
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
/** Mint an invitation in the admin UI and return its `/invite/<token>` path. */
|
||||
async function mintInvite(page: import("@playwright/test").Page) {
|
||||
await page.goto("/admin/invites");
|
||||
await page.getByTestId("admin-invites-open-create").click();
|
||||
await page.getByTestId("admin-invites-submit-create").click();
|
||||
const minted = page.getByTestId("admin-invites-minted");
|
||||
await expect(minted).toBeVisible();
|
||||
const url = await page.getByTestId("admin-invites-minted-url").textContent();
|
||||
expect(url).toMatch(/\/invite\/hlv_[0-9a-f]{64}$/);
|
||||
// The minted URL carries whatever origin the instance is configured with
|
||||
// (`APP_URL` / `NEXT_PUBLIC_APP_URL`), which is not necessarily the one
|
||||
// Playwright is driving. The path is the part under test.
|
||||
return new URL(url!.trim()).pathname;
|
||||
}
|
||||
|
||||
test.describe("invitation links", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test("opens registration in a cookie-less window and never passes login", async ({
|
||||
page,
|
||||
browser,
|
||||
}) => {
|
||||
const invitePath = await mintInvite(page);
|
||||
|
||||
// The operator's private window: a context with no cookies at all, not a
|
||||
// page that merely has not logged in.
|
||||
const fresh = await browser.newContext({
|
||||
storageState: { cookies: [], origins: [] },
|
||||
});
|
||||
const guest = await fresh.newPage();
|
||||
const visited: string[] = [];
|
||||
guest.on("framenavigated", (frame) => {
|
||||
if (frame === guest.mainFrame()) visited.push(frame.url());
|
||||
});
|
||||
|
||||
try {
|
||||
await guest.goto(invitePath);
|
||||
await expect(guest).toHaveURL(
|
||||
/\/auth\/register\?invite=hlv_[0-9a-f]{64}/,
|
||||
);
|
||||
await expect(guest.getByTestId("register-invite-banner")).toBeVisible();
|
||||
// The form is reachable, not just the URL.
|
||||
await expect(guest.locator("#username")).toBeVisible();
|
||||
// The defect, named: the visitor must never have been bounced through
|
||||
// the sign-in page on the way.
|
||||
expect(visited.filter((u) => u.includes("/auth/login"))).toEqual([]);
|
||||
} finally {
|
||||
// Contexts a spec creates itself are invisible to the route-cleanup
|
||||
// fixture — see the header of `e2e/setup/test.ts`.
|
||||
await fresh.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows the signed-in panel in the admin's own window and keeps the session", async ({
|
||||
page,
|
||||
}) => {
|
||||
const invitePath = await mintInvite(page);
|
||||
|
||||
await page.goto(invitePath);
|
||||
await expect(page).toHaveURL(/\/auth\/register\?invite=hlv_[0-9a-f]{64}/);
|
||||
await expect(page.getByTestId("register-already-signed-in")).toBeVisible();
|
||||
// No form to submit, so no way to register over the live session.
|
||||
await expect(page.locator("#username")).toHaveCount(0);
|
||||
await expect(page.getByTestId("register-sign-out")).toBeVisible();
|
||||
|
||||
// The admin is still the admin.
|
||||
const me = await page.request.get("/api/auth/me");
|
||||
expect(me.status()).toBe(200);
|
||||
});
|
||||
|
||||
test("refuses a registration POST carried on a live session", async ({
|
||||
page,
|
||||
}) => {
|
||||
// The server half of the same rule, asserted through the wire rather than
|
||||
// through the UI: the panel above is an explanation, not the enforcement.
|
||||
const res = await page.request.post("/api/auth/register", {
|
||||
data: {
|
||||
email: "invite-over-session@healthlog.test",
|
||||
username: "invite-over-session",
|
||||
password: "ZJ4hN8x!Pq3vMr2C",
|
||||
},
|
||||
});
|
||||
expect(res.status()).toBe(409);
|
||||
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
expect(body.meta?.errorCode).toBe("auth.already_authenticated");
|
||||
});
|
||||
});
|
||||
@@ -413,6 +413,11 @@
|
||||
"loginFailed": "Anmeldung fehlgeschlagen",
|
||||
"registerFailed": "Registrierung fehlgeschlagen",
|
||||
"invitedBanner": "Du wurdest eingeladen — erstelle dein Konto, auch wenn die Registrierung sonst geschlossen ist.",
|
||||
"alreadySignedIn": {
|
||||
"title": "Du bist bereits angemeldet",
|
||||
"body": "Du bist als {username} angemeldet. Eine Einladung legt ein neues Konto an. Melde dich ab, um sie anzunehmen.",
|
||||
"signOut": "Abmelden und Einladung annehmen"
|
||||
},
|
||||
"createAccount": "Konto erstellen",
|
||||
"newAccount": "Neues Konto erstellen",
|
||||
"backToPasskey": "Zurück zu Passkey",
|
||||
@@ -10322,6 +10327,7 @@
|
||||
"invite": {
|
||||
"title": "Jemanden einladen",
|
||||
"description": "Vor der Zustimmung wird nichts geteilt.",
|
||||
"needsAccount": "Die Person braucht ein eigenes HealthLog-Konto auf dieser Instanz und findet die Einladung dort unter Einstellungen → Geteilter Zugriff.",
|
||||
"identifierLabel": "Benutzername oder E-Mail",
|
||||
"identifierPlaceholder": "Der Name, mit dem sie sich anmeldet",
|
||||
"identifierHint": "Die Person braucht ein Konto auf dieser Instanz. An eine E-Mail-Adresse ohne Registrierung lässt sich keine Einladung schicken.",
|
||||
|
||||
@@ -413,6 +413,11 @@
|
||||
"loginFailed": "Login failed",
|
||||
"registerFailed": "Registration failed",
|
||||
"invitedBanner": "You have been invited — create your account even while registration is otherwise closed.",
|
||||
"alreadySignedIn": {
|
||||
"title": "You are already signed in",
|
||||
"body": "You are signed in as {username}. An invitation creates a new account — sign out to accept it.",
|
||||
"signOut": "Sign out and accept the invitation"
|
||||
},
|
||||
"createAccount": "Create account",
|
||||
"newAccount": "Create new account",
|
||||
"backToPasskey": "Back to Passkey",
|
||||
@@ -10322,6 +10327,7 @@
|
||||
"invite": {
|
||||
"title": "Invite someone",
|
||||
"description": "Nothing is shared until they accept.",
|
||||
"needsAccount": "The person needs their own HealthLog account on this instance and will find the invitation under Settings → Shared access.",
|
||||
"identifierLabel": "Username or e-mail",
|
||||
"identifierPlaceholder": "The name they sign in with",
|
||||
"identifierHint": "They need an account on this instance. Invitations cannot be sent to an e-mail address that has not registered.",
|
||||
|
||||
@@ -413,6 +413,11 @@
|
||||
"loginFailed": "No se ha podido iniciar sesión",
|
||||
"registerFailed": "No se ha podido completar el registro",
|
||||
"invitedBanner": "Te han invitado: crea tu cuenta aunque el registro esté cerrado.",
|
||||
"alreadySignedIn": {
|
||||
"title": "Ya has iniciado sesión",
|
||||
"body": "Has iniciado sesión como {username}. Una invitación crea una cuenta nueva. Cierra la sesión para aceptarla.",
|
||||
"signOut": "Cerrar sesión y aceptar la invitación"
|
||||
},
|
||||
"createAccount": "Crear cuenta",
|
||||
"newAccount": "Crear cuenta nueva",
|
||||
"backToPasskey": "Volver a passkey",
|
||||
@@ -10322,6 +10327,7 @@
|
||||
"invite": {
|
||||
"title": "Invitar a alguien",
|
||||
"description": "No se comparte nada hasta que acepte.",
|
||||
"needsAccount": "La persona necesita su propia cuenta de HealthLog en esta instancia y encontrará la invitación en Ajustes → Acceso compartido.",
|
||||
"identifierLabel": "Usuario o correo electrónico",
|
||||
"identifierPlaceholder": "El nombre con el que inicia sesión",
|
||||
"identifierHint": "Necesita una cuenta en esta instancia. No se pueden enviar invitaciones a una dirección que no se haya registrado.",
|
||||
|
||||
@@ -413,6 +413,11 @@
|
||||
"loginFailed": "Échec de la connexion",
|
||||
"registerFailed": "Échec de l’inscription",
|
||||
"invitedBanner": "Vous avez été invité — créez votre compte même si les inscriptions sont fermées.",
|
||||
"alreadySignedIn": {
|
||||
"title": "Tu es déjà connecté",
|
||||
"body": "Tu es connecté en tant que {username}. Une invitation crée un nouveau compte. Déconnecte-toi pour l'accepter.",
|
||||
"signOut": "Se déconnecter et accepter l'invitation"
|
||||
},
|
||||
"createAccount": "Créer un compte",
|
||||
"newAccount": "Créer un nouveau compte",
|
||||
"backToPasskey": "Revenir à la passkey",
|
||||
@@ -10322,6 +10327,7 @@
|
||||
"invite": {
|
||||
"title": "Inviter quelqu'un",
|
||||
"description": "Rien n'est partagé avant son acceptation.",
|
||||
"needsAccount": "La personne a besoin de son propre compte HealthLog sur cette instance et trouvera l'invitation dans Paramètres → Accès partagé.",
|
||||
"identifierLabel": "Nom d'utilisateur ou e-mail",
|
||||
"identifierPlaceholder": "Le nom avec lequel elle se connecte",
|
||||
"identifierHint": "Cette personne a besoin d'un compte sur cette instance. Une invitation ne peut pas être envoyée à une adresse non inscrite.",
|
||||
|
||||
@@ -413,6 +413,11 @@
|
||||
"loginFailed": "Accesso non riuscito",
|
||||
"registerFailed": "Registrazione non riuscita",
|
||||
"invitedBanner": "Sei stato invitato — crea il tuo account anche se la registrazione è chiusa.",
|
||||
"alreadySignedIn": {
|
||||
"title": "Hai già effettuato l'accesso",
|
||||
"body": "Hai effettuato l'accesso come {username}. Un invito crea un nuovo account. Esci per accettarlo.",
|
||||
"signOut": "Esci e accetta l'invito"
|
||||
},
|
||||
"createAccount": "Crea account",
|
||||
"newAccount": "Crea un nuovo account",
|
||||
"backToPasskey": "Torna a passkey",
|
||||
@@ -10322,6 +10327,7 @@
|
||||
"invite": {
|
||||
"title": "Invita qualcuno",
|
||||
"description": "Nulla viene condiviso finché non accetta.",
|
||||
"needsAccount": "La persona ha bisogno di un proprio account HealthLog su questa istanza e troverà l'invito in Impostazioni → Accesso condiviso.",
|
||||
"identifierLabel": "Nome utente o e-mail",
|
||||
"identifierPlaceholder": "Il nome con cui accede",
|
||||
"identifierHint": "Serve un account su questa istanza. Non è possibile inviare inviti a un indirizzo non registrato.",
|
||||
|
||||
@@ -413,6 +413,11 @@
|
||||
"loginFailed": "로그인 실패",
|
||||
"registerFailed": "회원가입 실패",
|
||||
"invitedBanner": "초대를 받았어요 — 회원가입이 닫혀 있어도 계정을 만들 수 있어요.",
|
||||
"alreadySignedIn": {
|
||||
"title": "이미 로그인되어 있습니다",
|
||||
"body": "{username} 계정으로 로그인되어 있습니다. 초대는 새 계정을 만듭니다. 초대를 수락하려면 로그아웃하세요.",
|
||||
"signOut": "로그아웃하고 초대 수락하기"
|
||||
},
|
||||
"createAccount": "계정 만들기",
|
||||
"newAccount": "새 계정 만들기",
|
||||
"backToPasskey": "패스키로 돌아가기",
|
||||
@@ -10322,6 +10327,7 @@
|
||||
"invite": {
|
||||
"title": "다른 사람 초대",
|
||||
"description": "상대가 수락하기 전에는 아무것도 공유되지 않아요.",
|
||||
"needsAccount": "상대방에게는 이 인스턴스의 HealthLog 계정이 필요하며, 초대는 설정 → 공유 접근에서 확인할 수 있습니다.",
|
||||
"identifierLabel": "사용자 이름 또는 이메일",
|
||||
"identifierPlaceholder": "로그인에 쓰는 이름",
|
||||
"identifierHint": "이 인스턴스에 계정이 있어야 해요. 가입하지 않은 이메일 주소로는 초대를 보낼 수 없어요.",
|
||||
|
||||
@@ -413,6 +413,11 @@
|
||||
"loginFailed": "Logowanie nie powiodło się",
|
||||
"registerFailed": "Rejestracja nie powiodła się",
|
||||
"invitedBanner": "Otrzymano zaproszenie — utwórz konto, nawet jeśli rejestracja jest zamknięta.",
|
||||
"alreadySignedIn": {
|
||||
"title": "Masz już zalogowaną sesję",
|
||||
"body": "Masz zalogowaną sesję jako {username}. Zaproszenie tworzy nowe konto. Wyloguj się, aby je przyjąć.",
|
||||
"signOut": "Wyloguj się i przyjmij zaproszenie"
|
||||
},
|
||||
"createAccount": "Utwórz konto",
|
||||
"newAccount": "Utwórz nowe konto",
|
||||
"backToPasskey": "Wróć do passkey",
|
||||
@@ -10322,6 +10327,7 @@
|
||||
"invite": {
|
||||
"title": "Zaproś kogoś",
|
||||
"description": "Nic nie jest udostępniane, dopóki ta osoba nie zaakceptuje.",
|
||||
"needsAccount": "Ta osoba potrzebuje własnego konta HealthLog na tej instancji i znajdzie zaproszenie w Ustawienia → Udostępniony dostęp.",
|
||||
"identifierLabel": "Nazwa użytkownika lub e-mail",
|
||||
"identifierPlaceholder": "Nazwa, którą się loguje",
|
||||
"identifierHint": "Ta osoba potrzebuje konta na tej instancji. Nie można wysłać zaproszenia na niezarejestrowany adres.",
|
||||
|
||||
@@ -8,6 +8,16 @@ import { NextRequest } from "next/server";
|
||||
* visitor (scanning the admin QR) must reach it without the auth-gate
|
||||
* bounce to `/auth/login`. This guard locks the allowlist entry in place
|
||||
* so a future refactor of `src/proxy.ts` cannot silently re-gate it.
|
||||
*
|
||||
* v1.38.19 — the redirect moved here, to the edge. It used to be emitted by
|
||||
* `src/app/invite/[token]/page.tsx`, and Next does not answer that with a
|
||||
* server 307: it streams the root layout first and puts the redirect in the
|
||||
* flight payload, so `<AuthShell>` mounted on `/invite/…`, read the pathname
|
||||
* as a protected route and raced its own `router.replace("/auth/login")`
|
||||
* against the redirect. Answering at the edge means there is no HTML, no
|
||||
* hydration and no race — the behaviour the page's doc comment already
|
||||
* claimed. The page stays as the fallback for any path that bypasses the
|
||||
* proxy matcher.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/process-type", () => ({
|
||||
@@ -33,19 +43,18 @@ function makeRequest(
|
||||
describe("proxy.ts /invite public-allowlist (v1.17.0)", () => {
|
||||
it("does not 307 an unauthenticated /invite/<token> to /auth/login", () => {
|
||||
const res = proxy(makeRequest(`/invite/${VALID_TOKEN}`));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("location")).toBeNull();
|
||||
expect(res.headers.get("location") ?? "").not.toMatch(/\/auth\/login/);
|
||||
});
|
||||
|
||||
it("admits a pending-onboarding session onto the invite landing", () => {
|
||||
it("does not bounce a pending-onboarding session off the invite landing", () => {
|
||||
const res = proxy(
|
||||
makeRequest(`/invite/${VALID_TOKEN}`, {
|
||||
healthlog_session: "sess-1",
|
||||
hl_onboarding: "pending",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("location")).toBeNull();
|
||||
expect(res.headers.get("location") ?? "").not.toMatch(/\/onboarding/);
|
||||
expect(res.headers.get("location") ?? "").not.toMatch(/\/auth\/login/);
|
||||
});
|
||||
|
||||
it("hardens the token-in-path surface against cache / index / referrer leaks", () => {
|
||||
@@ -67,3 +76,64 @@ describe("proxy.ts /invite public-allowlist (v1.17.0)", () => {
|
||||
expect(res.headers.get("location")).toMatch(/\/auth\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("proxy.ts /invite edge redirect (v1.38.19)", () => {
|
||||
it("307s a shape-valid token straight onto the registration form", () => {
|
||||
const res = proxy(makeRequest(`/invite/${VALID_TOKEN}`));
|
||||
expect(res.status).toBe(307);
|
||||
expect(res.headers.get("location")).toBe(
|
||||
`http://localhost/auth/register?invite=${VALID_TOKEN}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("307s a malformed segment onto a bare registration form", () => {
|
||||
// Never echo an attacker-controlled segment into the target — a
|
||||
// malformed token behaves exactly like a visitor with no invite.
|
||||
const res = proxy(makeRequest("/invite/not-a-token"));
|
||||
expect(res.status).toBe(307);
|
||||
expect(res.headers.get("location")).toBe("http://localhost/auth/register");
|
||||
});
|
||||
|
||||
it("ignores anything after the token segment", () => {
|
||||
const res = proxy(makeRequest(`/invite/${VALID_TOKEN}/extra`));
|
||||
expect(res.headers.get("location")).toBe(
|
||||
`http://localhost/auth/register?invite=${VALID_TOKEN}`,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an uppercase hex body", `hlv_${"A".repeat(64)}`],
|
||||
["a short body", `hlv_${"a".repeat(63)}`],
|
||||
["a long body", `hlv_${"a".repeat(65)}`],
|
||||
["the wrong prefix", `hlk_${"a".repeat(64)}`],
|
||||
["an empty segment", ""],
|
||||
])("drops %s rather than echoing it into the target", (_label, segment) => {
|
||||
const res = proxy(makeRequest(`/invite/${segment}`));
|
||||
expect(res.headers.get("location")).toBe("http://localhost/auth/register");
|
||||
});
|
||||
|
||||
it("keeps the token hardening on the malformed redirect too", () => {
|
||||
const res = proxy(makeRequest("/invite/not-a-token"));
|
||||
expect(res.headers.get("Cache-Control")).toBe(
|
||||
"no-store, no-cache, must-revalidate",
|
||||
);
|
||||
expect(res.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
|
||||
expect(res.headers.get("Referrer-Policy")).toBe("no-referrer");
|
||||
});
|
||||
|
||||
it("carries the baseline transport headers on the redirect hop", () => {
|
||||
const res = proxy(makeRequest(`/invite/${VALID_TOKEN}`));
|
||||
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
||||
expect(res.headers.get("X-Frame-Options")).toBe("DENY");
|
||||
});
|
||||
|
||||
it("redirects a signed-in caller the same way, with no session read", () => {
|
||||
const res = proxy(
|
||||
makeRequest(`/invite/${VALID_TOKEN}`, { healthlog_session: "sess-1" }),
|
||||
);
|
||||
expect(res.status).toBe(307);
|
||||
expect(res.headers.get("location")).toBe(
|
||||
`http://localhost/auth/register?invite=${VALID_TOKEN}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,12 @@ describe("S1 — the direct session-resolution set is frozen", () => {
|
||||
// the freshness comparison, which `requireAuth` does not project.
|
||||
"app/api/auth/native/complete/route.ts":
|
||||
"reads createdAt for the handoff freshness check",
|
||||
// Anonymous self-registration. Probes for an already-valid session so an
|
||||
// invitation opened from inside one is refused instead of minting a second
|
||||
// account over the caller's own cookie. `requireAuth` is the wrong gate
|
||||
// here twice over: it would throw 401 at the anonymous caller the route
|
||||
// exists for, and this route wants the ABSENCE of a session.
|
||||
"app/api/auth/register/route.ts": "refuses to register over a live session",
|
||||
// Anonymous IdP callback. Probes for an already-valid session so a
|
||||
// replayed callback redirects instead of minting a second identity.
|
||||
"app/api/auth/oidc/callback/route.ts":
|
||||
|
||||
@@ -31,8 +31,19 @@ vi.mock("@/lib/auth/login-alert", () => ({
|
||||
recordSignInDevice: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// The invite ledger. A refused registration must never reach it — the
|
||||
// assertion below is that `uses` is left alone, and the only way this route
|
||||
// can touch it is through `consumeInviteToken`.
|
||||
vi.mock("@/lib/auth/invite-token", () => ({
|
||||
consumeInviteToken: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, inviteId: "inv-1" }),
|
||||
recordInviteConsumer: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
createSession: vi.fn().mockResolvedValue(undefined),
|
||||
getSession: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/password", () => ({
|
||||
@@ -78,7 +89,8 @@ import { POST } from "../route";
|
||||
import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkPasswordStrength, hashPassword } from "@/lib/auth/password";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { createSession, getSession } from "@/lib/auth/session";
|
||||
import { consumeInviteToken } from "@/lib/auth/invite-token";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
@@ -96,6 +108,7 @@ beforeEach(() => {
|
||||
allowed: true,
|
||||
ip: "1.2.3.4",
|
||||
} as never);
|
||||
vi.mocked(getSession).mockResolvedValue(null as never);
|
||||
});
|
||||
|
||||
describe("POST /api/auth/register — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
@@ -267,3 +280,97 @@ describe("POST /api/auth/register — OIDC_ONLY server-side enforcement", () =>
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.38.19 — an invitation creates a NEW account, so it cannot be accepted
|
||||
* from inside somebody's live session.
|
||||
*
|
||||
* Until this gate the route read no session at all: a request carrying a valid
|
||||
* `healthlog_session` was treated exactly like an anonymous one, and the
|
||||
* unconditional `createSession` at the end overwrote the caller's cookie with
|
||||
* the fresh account's. The admin who clicked his own invite link to check it
|
||||
* was signed out of his admin account and into a stranger's onboarding, with
|
||||
* his previous session row left valid server-side and unreachable.
|
||||
*/
|
||||
describe("POST /api/auth/register — refuses a live session (v1.38.19)", () => {
|
||||
const invitedBody = {
|
||||
email: "new@example.com",
|
||||
username: "newuser",
|
||||
password: "a-very-strong-password-123",
|
||||
inviteToken: `hlv_${"a".repeat(64)}`,
|
||||
};
|
||||
|
||||
// Everything the happy path needs is stubbed, so the ONLY reason this
|
||||
// request can fail is the gate under test. Without it the same request
|
||||
// answers 201 and mints a session over the caller's cookie.
|
||||
beforeEach(() => {
|
||||
vi.mocked(consumeInviteToken).mockResolvedValue({
|
||||
ok: true,
|
||||
inviteId: "inv-1",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(1 as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
|
||||
vi.mocked(checkPasswordStrength).mockReturnValue({
|
||||
isAcceptable: true,
|
||||
feedback: [],
|
||||
} as never);
|
||||
vi.mocked(hashPassword).mockResolvedValue("hashed" as never);
|
||||
vi.mocked(prisma.$transaction).mockImplementation(((
|
||||
fn: (tx: typeof prisma) => unknown,
|
||||
) => fn(prisma)) as never);
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ locked: 1 }] as never);
|
||||
vi.mocked(prisma.user.create).mockResolvedValue({
|
||||
id: "u1",
|
||||
username: "newuser",
|
||||
email: "new@example.com",
|
||||
role: "USER",
|
||||
} as never);
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 1e6) },
|
||||
user: { id: "u-existing", username: "admin", email: "a@example.com" },
|
||||
} as never);
|
||||
});
|
||||
|
||||
it("answers 409 already_authenticated", async () => {
|
||||
const res = await postPathThrough(invitedBody);
|
||||
expect(res.status).toBe(409);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.meta?.errorCode).toBe("auth.already_authenticated");
|
||||
expect(body.error).toMatch(/sign/i);
|
||||
});
|
||||
|
||||
it("creates no account", async () => {
|
||||
await postPathThrough(invitedBody);
|
||||
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not burn a use of the invitation", async () => {
|
||||
await postPathThrough(invitedBody);
|
||||
expect(consumeInviteToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not touch the caller's own session", async () => {
|
||||
await postPathThrough(invitedBody);
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses before the rate-limit bucket is spent", async () => {
|
||||
// The gate is a session read, not an attempt: a signed-in visitor
|
||||
// clicking the link three times must not lock the door for the person
|
||||
// the invitation is actually for, who shares the IP on a home network.
|
||||
await postPathThrough(invitedBody);
|
||||
expect(checkAuthSurfaceRateLimit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still registers an anonymous caller", async () => {
|
||||
// The control. Same request, no session — the invitation works.
|
||||
vi.mocked(getSession).mockResolvedValue(null as never);
|
||||
const res = await postPathThrough(invitedBody);
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "@/lib/auth/invite-token";
|
||||
import { registerSchema } from "@/lib/validations/auth";
|
||||
import { hashPassword, checkPasswordStrength } from "@/lib/auth/password";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { createSession, getSession } from "@/lib/auth/session";
|
||||
import { recordSignInDevice } from "@/lib/auth/login-alert";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -39,6 +39,31 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
// v1.38.19 — an invitation creates a NEW account, so it can never be
|
||||
// accepted from inside a live session.
|
||||
//
|
||||
// The route used to read no session at all: a request carrying a valid
|
||||
// `healthlog_session` was handled exactly like an anonymous one, and the
|
||||
// unconditional `createSession` at the end overwrote the caller's cookie
|
||||
// with the fresh account's. An admin opening his own invite link to check it
|
||||
// was signed out of his admin account and into a stranger's onboarding, his
|
||||
// previous session row still valid server-side and no longer reachable.
|
||||
//
|
||||
// Refused BEFORE the rate limit on purpose: this is a state, not an attempt,
|
||||
// and spending the 5-per-15-minutes bucket on it would lock the door for the
|
||||
// person the invitation is actually for, who may well share the IP.
|
||||
// `getSession` is the optional resolver — it answers `null` for an anonymous
|
||||
// caller instead of throwing, which `requireAuth` would.
|
||||
const existingSession = await getSession();
|
||||
if (existingSession) {
|
||||
annotate({ action: { name: "auth.register.refused_authenticated" } });
|
||||
return apiError(
|
||||
"Already signed in — sign out first to use an invitation",
|
||||
409,
|
||||
{ errorCode: "auth.already_authenticated" },
|
||||
);
|
||||
}
|
||||
|
||||
// v1.4.43 W13 M-4 — tighten to a global bucket when the trust chain
|
||||
// is misconfigured; otherwise byte-equivalent per-IP semantics.
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* An invitation opened from inside a live session.
|
||||
*
|
||||
* `POST /api/auth/register` refuses that with 409 `already_authenticated`
|
||||
* (see `src/app/api/auth/register/__tests__/route.test.ts`). The page must not
|
||||
* make the visitor discover it by filling the form: a signed-in caller gets an
|
||||
* explanation and a way out instead of three inputs that cannot succeed.
|
||||
*
|
||||
* SSR-only, per project convention (node environment, no DOM, no
|
||||
* `@testing-library/react`), so what is asserted is the PAINT — which branch
|
||||
* the page rendered. The sign-out click itself is driven in
|
||||
* `e2e/invite-registration.spec.ts`.
|
||||
*
|
||||
* Mutation check, run: drop the `isAuthenticated` branch → the two signed-in
|
||||
* cases go red naming the form inputs they found, the anonymous case stays
|
||||
* green.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
const authRef = {
|
||||
value: {
|
||||
user: null as { username: string } | null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
},
|
||||
};
|
||||
const inviteRef = { value: null as string | null };
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
|
||||
useSearchParams: () => ({
|
||||
get: (key: string) => (key === "invite" ? inviteRef.value : null),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => authRef.value,
|
||||
clearCachesForSessionEnd: vi.fn(),
|
||||
}));
|
||||
|
||||
import RegisterPage from "../page";
|
||||
|
||||
const TOKEN = `hlv_${"a".repeat(64)}`;
|
||||
|
||||
function render(): string {
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<RegisterPage />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function anonymous(invite: string | null): string {
|
||||
authRef.value = { user: null, isAuthenticated: false, isLoading: false };
|
||||
inviteRef.value = invite;
|
||||
return render();
|
||||
}
|
||||
|
||||
function signedIn(invite: string | null, username = "marc"): string {
|
||||
authRef.value = {
|
||||
user: { username },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
};
|
||||
inviteRef.value = invite;
|
||||
return render();
|
||||
}
|
||||
|
||||
describe("register page — an invitation cannot be accepted from a live session", () => {
|
||||
it("renders the form for an anonymous visitor holding an invitation", () => {
|
||||
const markup = anonymous(TOKEN);
|
||||
expect(markup).toContain('data-testid="register-invite-banner"');
|
||||
expect(markup).toContain('id="username"');
|
||||
expect(markup).not.toContain('data-testid="register-already-signed-in"');
|
||||
});
|
||||
|
||||
it("replaces the form with the explaining panel for a signed-in visitor", () => {
|
||||
const markup = signedIn(TOKEN);
|
||||
expect(markup).toContain('data-testid="register-already-signed-in"');
|
||||
expect(markup).not.toContain('id="username"');
|
||||
expect(markup).not.toContain('id="password"');
|
||||
});
|
||||
|
||||
it("names the account the visitor is signed in as", () => {
|
||||
const markup = signedIn(TOKEN, "grandma");
|
||||
expect(markup).toContain("grandma");
|
||||
});
|
||||
|
||||
it("offers the way out", () => {
|
||||
const markup = signedIn(TOKEN);
|
||||
expect(markup).toContain('data-testid="register-sign-out"');
|
||||
});
|
||||
|
||||
it("explains the same thing without an invitation in the URL", () => {
|
||||
// Reaching /auth/register signed in is the same dead end with or without
|
||||
// a token — the form below could only ever answer 409.
|
||||
const markup = signedIn(null);
|
||||
expect(markup).toContain('data-testid="register-already-signed-in"');
|
||||
});
|
||||
|
||||
it("holds the panel back while the session question is unanswered", () => {
|
||||
// Fail closed the other way round: an unresolved `/api/auth/me` must not
|
||||
// flash "you are already signed in" at somebody who is not.
|
||||
authRef.value = { user: null, isAuthenticated: false, isLoading: true };
|
||||
inviteRef.value = TOKEN;
|
||||
const markup = render();
|
||||
expect(markup).not.toContain('data-testid="register-already-signed-in"');
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useId, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, MailOpen, UserPlus } from "lucide-react";
|
||||
import { Loader2, LogOut, MailOpen, UserPlus } from "lucide-react";
|
||||
import { Logo } from "@/components/ui/logo";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -13,7 +13,8 @@ import { PasswordStrength } from "@/components/ui/password-strength";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { detectBrowserTimezone } from "@/lib/tz/format";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { ApiError, apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
|
||||
import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
@@ -23,6 +24,15 @@ export default function RegisterPage() {
|
||||
// admits the invited user; an open instance simply ignores it.
|
||||
const inviteToken = searchParams.get("invite");
|
||||
const queryClient = useQueryClient();
|
||||
// v1.38.19 — an invitation creates a NEW account, so it cannot be accepted
|
||||
// from inside a live session; `POST /api/auth/register` refuses one with
|
||||
// 409 `already_authenticated`. Rendering the form anyway would let the
|
||||
// visitor discover that by filling it in. `isLoading` holds the panel back
|
||||
// until the session question is actually answered — failing closed the
|
||||
// other way round would flash "you are already signed in" at somebody who
|
||||
// is not.
|
||||
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const [signingOut, setSigningOut] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -69,6 +79,83 @@ export default function RegisterPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSignOut() {
|
||||
setError(null);
|
||||
setSigningOut(true);
|
||||
try {
|
||||
await apiFetchRaw("/api/auth/logout", { method: "POST" });
|
||||
// Same session-end wipe the shell and the menu do: the QueryClient
|
||||
// outlives the navigation on this SPA and the health-data families are
|
||||
// not user-scoped, so nothing may survive into the next account.
|
||||
clearCachesForSessionEnd(queryClient);
|
||||
// Back to this page, invitation intact — NOT to the login screen the
|
||||
// generic logout goes to. The point of the trip is to accept the
|
||||
// invitation, and the token only rides the URL.
|
||||
router.replace(
|
||||
inviteToken
|
||||
? `/auth/register?invite=${encodeURIComponent(inviteToken)}`
|
||||
: "/auth/register",
|
||||
);
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError(t("common.networkError"));
|
||||
} finally {
|
||||
setSigningOut(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isAuthenticated && !authLoading) {
|
||||
return (
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="border-border bg-card rounded-xl border p-6 shadow-lg shadow-black/20 sm:p-8">
|
||||
<div
|
||||
className="flex flex-col items-center gap-3 text-center"
|
||||
data-testid="register-already-signed-in"
|
||||
>
|
||||
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<Logo className="text-primary" size={28} />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold tracking-tight">
|
||||
{t("auth.alreadySignedIn.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("auth.alreadySignedIn.body", {
|
||||
username: user?.username ?? "",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="bg-destructive/10 text-destructive mt-6 rounded-lg p-3 text-sm"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
className="mt-8 min-h-11 w-full"
|
||||
onClick={handleSignOut}
|
||||
disabled={signingOut}
|
||||
aria-busy={signingOut || undefined}
|
||||
data-testid="register-sign-out"
|
||||
>
|
||||
{signingOut ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<LogOut className="h-4 w-4" />
|
||||
)}
|
||||
{t("auth.alreadySignedIn.signOut")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-sm">
|
||||
{/* v1.4.27 MB7 / CF-61 — drop `p-8` to `p-6 sm:p-8` so the
|
||||
|
||||
@@ -619,7 +619,10 @@ export function InviteTokensSection() {
|
||||
height={176}
|
||||
className="rounded-lg bg-white p-2"
|
||||
/>
|
||||
<p className="bg-muted w-full overflow-x-auto rounded-md p-2 text-center font-mono text-xs break-all select-all">
|
||||
<p
|
||||
className="bg-muted w-full overflow-x-auto rounded-md p-2 text-center font-mono text-xs break-all select-all"
|
||||
data-testid="admin-invites-minted-url"
|
||||
>
|
||||
{minted.url}
|
||||
</p>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* The edge gate and the client gate must agree about which PAGES are public.
|
||||
*
|
||||
* `src/proxy.ts` decides whether a request reaches the app at all;
|
||||
* `auth-shell.tsx` decides whether the mounted shell treats the pathname as
|
||||
* public or as a protected route that needs `/api/auth/me`. The two lists are
|
||||
* maintained by hand, and when they drift the visitor pays: Next streams the
|
||||
* shell before an RSC redirect resolves, so a route the edge admits but the
|
||||
* shell classifies as protected renders the protected branch and fires
|
||||
* `router.replace("/auth/login")` against the redirect. That is exactly what
|
||||
* happened to `/invite/<token>` — public in `proxy.ts` since v1.17.0, never
|
||||
* added here, and the operator's invite link landed on the login page.
|
||||
*
|
||||
* So this file does not pin one route. It parameterises over every PAGE prefix
|
||||
* the proxy admits and asserts the shell paints the page for each, which makes
|
||||
* the drift itself the thing under test rather than the one instance of it.
|
||||
*
|
||||
* SSR-only, per project convention (node environment, no DOM, no
|
||||
* `@testing-library/react`): effects never run here, so the assertion is the
|
||||
* PAINT. On a public path the shell hands the child through; on a protected
|
||||
* path with auth resolved as unauthenticated it paints the redirect spinner
|
||||
* instead and the child never appears. The production evidence for the defect
|
||||
* was the same signal read off a `curl` — `record-scope-hydration-gate` in the
|
||||
* body of `GET /invite/<token>`.
|
||||
*
|
||||
* Mutation check, run: remove `"/invite/"` from the shell's `PUBLIC_PATHS` →
|
||||
* the `/invite/` case goes red with the child sentinel missing, every other
|
||||
* case stays green.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
const mockPathRef = { value: "/" };
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => mockPathRef.value,
|
||||
useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isAuthUnknown: false,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
clearCachesForSessionEnd: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-record-session-transition", () => ({
|
||||
useRecordSessionTransition: () => ({
|
||||
id: "none",
|
||||
phase: "ready",
|
||||
expectedScope: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Chrome that is not the subject — each pulls its own query cells and portals.
|
||||
vi.mock("../sidebar-nav", () => ({ SidebarNav: () => null }));
|
||||
vi.mock("../bottom-nav", () => ({ BottomNav: () => null }));
|
||||
vi.mock("../top-bar", () => ({ TopBar: () => null }));
|
||||
vi.mock("../offline-banner", () => ({ OfflineBanner: () => null }));
|
||||
vi.mock("../demo-banner", () => ({ DemoBanner: () => null }));
|
||||
vi.mock("../shared-record-banner", () => ({ SharedRecordBanner: () => null }));
|
||||
vi.mock("../shared-record-unavailable", () => ({
|
||||
SharedRecordUnavailable: () => null,
|
||||
}));
|
||||
vi.mock("@/components/i18n/maintainership-banner", () => ({
|
||||
MaintainershipBanner: () => null,
|
||||
}));
|
||||
vi.mock("@/components/gamification/achievement-unlock-notifier", () => ({
|
||||
AchievementUnlockNotifier: () => null,
|
||||
}));
|
||||
vi.mock("@/components/insights/layout-coach-fab", () => ({
|
||||
LayoutCoachFab: () => null,
|
||||
}));
|
||||
vi.mock("@/components/insights/layout-coach-mount", () => ({
|
||||
LayoutCoachMount: () => null,
|
||||
}));
|
||||
vi.mock("@/components/onboarding/tour-launcher", () => ({
|
||||
TourLauncher: () => null,
|
||||
}));
|
||||
|
||||
import { AuthShell } from "../auth-shell";
|
||||
// The proxy's list is the source of truth for what the edge lets through.
|
||||
import { PUBLIC_PATHS as PROXY_PUBLIC_PATHS } from "@/proxy";
|
||||
|
||||
/**
|
||||
* Prefixes the proxy admits that are NOT pages — route handlers with no
|
||||
* React tree, so `<AuthShell>` never mounts on them and has nothing to say
|
||||
* about them. Named explicitly rather than pattern-matched: a new non-page
|
||||
* public prefix should turn this file red once, so its author classifies it
|
||||
* here deliberately instead of a regex swallowing a real page.
|
||||
*/
|
||||
const NON_PAGE_PREFIXES = new Set([
|
||||
"/i18n/", // locale-catalog boot script
|
||||
"/robots.txt", // static text route
|
||||
]);
|
||||
|
||||
const PAGE_PREFIXES = PROXY_PUBLIC_PATHS.filter(
|
||||
(p) =>
|
||||
!p.startsWith("/api/") &&
|
||||
!p.startsWith("/.well-known") &&
|
||||
!NON_PAGE_PREFIXES.has(p),
|
||||
);
|
||||
|
||||
function render(pathname: string): string {
|
||||
mockPathRef.value = pathname;
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<AuthShell>
|
||||
<div data-slot="sentinel-page" />
|
||||
</AuthShell>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A representative pathname under a prefix. A trailing-slash prefix admits
|
||||
* EVERY path beneath it at the edge, so the sample is a segment that no
|
||||
* narrower shell entry could be covering by accident — otherwise the guard
|
||||
* would pass on the one page that happens to exist there today and miss the
|
||||
* next one.
|
||||
*/
|
||||
function sampleFor(prefix: string): string {
|
||||
return prefix.endsWith("/") ? `${prefix}probe` : prefix;
|
||||
}
|
||||
|
||||
describe("AuthShell public paths agree with proxy.ts", () => {
|
||||
it("has page prefixes to check at all", () => {
|
||||
expect(PAGE_PREFIXES.length).toBeGreaterThan(0);
|
||||
expect(PAGE_PREFIXES).toContain("/invite/");
|
||||
});
|
||||
|
||||
it.each(PAGE_PREFIXES)(
|
||||
"renders the page for %s instead of the sign-in gate",
|
||||
(prefix) => {
|
||||
const markup = render(sampleFor(prefix));
|
||||
expect(markup).toContain('data-slot="sentinel-page"');
|
||||
},
|
||||
);
|
||||
|
||||
it("renders the invite landing for a real token path", () => {
|
||||
// The instance of the class: the operator's link, byte for byte.
|
||||
const markup = render(`/invite/hlv_${"a".repeat(64)}`);
|
||||
expect(markup).toContain('data-slot="sentinel-page"');
|
||||
});
|
||||
|
||||
it("still gates a protected route", () => {
|
||||
// Negative check — confirms the fixture would catch a real regression.
|
||||
const markup = render("/insights");
|
||||
expect(markup).not.toContain('data-slot="sentinel-page"');
|
||||
});
|
||||
});
|
||||
@@ -40,8 +40,12 @@ import { TopBar } from "./top-bar";
|
||||
// both lists must agree or the route round-trips through the
|
||||
// sign-in redirect.
|
||||
const PUBLIC_PATHS = [
|
||||
"/auth/login",
|
||||
"/auth/register",
|
||||
// The whole `/auth/` family, exactly as `proxy.ts` admits it. Listed as the
|
||||
// prefix rather than as `/auth/login` + `/auth/register`: the edge already
|
||||
// lets every path under it through without a session, so a future auth page
|
||||
// that the shell did not know about would mount the protected branch and
|
||||
// bounce its own visitor to the sign-in it is trying to replace.
|
||||
"/auth/",
|
||||
"/privacy",
|
||||
"/about",
|
||||
// v1.11.0 — the public clinician view renders its own standalone chrome
|
||||
@@ -49,6 +53,12 @@ const PUBLIC_PATHS = [
|
||||
// bare; the route is also in `proxy.ts` PUBLIC_PATHS so it never round-trips
|
||||
// the sign-in redirect.
|
||||
"/c/",
|
||||
// v1.17.0 — the invite universal-link landing is a server redirect onto
|
||||
// `/auth/register?invite=…`. It is public in `proxy.ts`, and it must be
|
||||
// public here too: Next streams this shell before the redirect resolves,
|
||||
// so without the entry the shell classifies the route as protected and
|
||||
// `router.replace("/auth/login")` wins the race against the redirect.
|
||||
"/invite/",
|
||||
];
|
||||
|
||||
export function AuthShell({
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* The grant card says where the invitation will turn up.
|
||||
*
|
||||
* A sharing invitation carries no link and nothing is e-mailed: it is
|
||||
* addressed to a username or e-mail of an account that already exists here,
|
||||
* and the person accepts it from inside their own session (`POST
|
||||
* /api/account/grants/[id]/accept`). The card said the first half — the input
|
||||
* hint already names the account requirement — and left the second half
|
||||
* unsaid, so an inviter who then goes looking for a link to send has nothing
|
||||
* to find and no reason to stop looking.
|
||||
*
|
||||
* SSR-only, per project convention.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", username: "owner", modules: {} },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { GrantInviteCard } from "../grant-invite-card";
|
||||
import en from "../../../../../messages/en.json";
|
||||
|
||||
function render(): string {
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<GrantInviteCard />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("grant invite card — the invitee needs an account here", () => {
|
||||
it("says where the invited person will find the invitation", () => {
|
||||
const markup = render();
|
||||
expect(markup).toContain(en.recordSharing.invite.needsAccount);
|
||||
});
|
||||
|
||||
it("names Settings → Shared access rather than a link to send", () => {
|
||||
expect(en.recordSharing.invite.needsAccount).toMatch(/Settings/);
|
||||
expect(en.recordSharing.invite.needsAccount).not.toMatch(/https?:/);
|
||||
});
|
||||
});
|
||||
@@ -150,7 +150,14 @@ export function GrantInviteCard() {
|
||||
<SettingsCardHeader
|
||||
icon={UserPlus}
|
||||
title={t("recordSharing.invite.title")}
|
||||
description={t("recordSharing.invite.description")}
|
||||
// A sharing invitation carries no link and nothing is e-mailed: the
|
||||
// person accepts it from inside their own session. Said here, at the
|
||||
// top of the card, so an inviter never goes looking for a link to
|
||||
// send — the invite-link surface is Admin → Invites, and it is a
|
||||
// different thing entirely.
|
||||
description={`${t("recordSharing.invite.description")} ${t(
|
||||
"recordSharing.invite.needsAccount",
|
||||
)}`}
|
||||
/>
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -46,6 +46,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
],
|
||||
auth: [
|
||||
"auth.admin.required",
|
||||
"auth.already_authenticated",
|
||||
"auth.mfa.code_invalid",
|
||||
"auth.missing",
|
||||
"auth.refresh.invalid",
|
||||
|
||||
+40
-1
@@ -13,7 +13,7 @@ import {
|
||||
* compose healthcheck rely on it. `/api/health` stays public for the same
|
||||
* reason.
|
||||
*/
|
||||
const PUBLIC_PATHS = [
|
||||
export const PUBLIC_PATHS = [
|
||||
"/auth/",
|
||||
"/api/auth/login",
|
||||
"/api/auth/register",
|
||||
@@ -330,6 +330,45 @@ export function proxy(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// v1.38.19 — the invite universal-link landing answers at the EDGE.
|
||||
//
|
||||
// `src/app/invite/[token]/page.tsx` has always redirected onto
|
||||
// `/auth/register?invite=…`, but Next does not deliver that as a server 307:
|
||||
// it streams the root layout first and puts the redirect in the RSC flight
|
||||
// payload. `<AuthShell>` therefore mounted on the pathname `/invite/…`, and
|
||||
// before the entry added beside this one it read the route as protected,
|
||||
// took a 401 from `/api/auth/me` and raced its own
|
||||
// `router.replace("/auth/login")` against the redirect — logged out the
|
||||
// login bounce won, and the visitor held a one-time invite on a page that
|
||||
// cannot use it. Answering here means no HTML, no hydration, no race.
|
||||
//
|
||||
// The page stays as the fallback for any path that bypasses the matcher.
|
||||
// The shape gate is the twin of `looksLikeInviteToken` in
|
||||
// `src/lib/auth/invite-token.ts`, restated rather than imported: that module
|
||||
// pulls the Prisma client, which has no business in the proxy bundle. A
|
||||
// segment that fails it is dropped rather than reflected, so the target can
|
||||
// never echo attacker-controlled text.
|
||||
if (pathname.startsWith("/invite/")) {
|
||||
const token = pathname.slice("/invite/".length).split("/")[0] ?? "";
|
||||
const target = new URL("/auth/register", request.url);
|
||||
if (/^hlv_[0-9a-f]{64}$/.test(token)) {
|
||||
target.searchParams.set("invite", token);
|
||||
}
|
||||
const redirected = applyBaselineSecurityHeaders(
|
||||
NextResponse.redirect(target, 307),
|
||||
);
|
||||
// The secret rides in the path of the request that produced this hop, so
|
||||
// the hop itself carries the same defence the landing page earned in
|
||||
// v1.17.0: uncacheable, unindexable, and never leaked as a `Referer`.
|
||||
redirected.headers.set(
|
||||
"Cache-Control",
|
||||
"no-store, no-cache, must-revalidate",
|
||||
);
|
||||
redirected.headers.set("X-Robots-Tag", "noindex, nofollow");
|
||||
redirected.headers.set("Referrer-Policy", "no-referrer");
|
||||
return redirected;
|
||||
}
|
||||
|
||||
// Server-side route protection for pages (not API routes — those have their own getSession checks)
|
||||
const isApiRoute = pathname.startsWith("/api/");
|
||||
const isStaticFile = /\.\w+$/.test(pathname);
|
||||
|
||||
Reference in new issue
Block a user