diff --git a/messages/de.json b/messages/de.json index 3be20d373..63fdbe632 100644 --- a/messages/de.json +++ b/messages/de.json @@ -61,6 +61,9 @@ "signIn": "Anmelden" }, "apiErrors": { + "http": { + "rate_limited": "Zu viele Versuche. Warte ein paar Minuten und versuche es erneut." + }, "measurement": { "create": { "invalid": "Prüfe die Messdaten und versuche es erneut." diff --git a/messages/en.json b/messages/en.json index b97815674..87b72faf8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -61,6 +61,9 @@ "signIn": "Sign in" }, "apiErrors": { + "http": { + "rate_limited": "Too many attempts. Wait a few minutes and try again." + }, "measurement": { "create": { "invalid": "Check the measurement details and try again." diff --git a/messages/es.json b/messages/es.json index 29be29505..32e0e3b75 100644 --- a/messages/es.json +++ b/messages/es.json @@ -61,6 +61,9 @@ "signIn": "Iniciar sesión" }, "apiErrors": { + "http": { + "rate_limited": "Demasiados intentos. Espera unos minutos y vuelve a intentarlo." + }, "measurement": { "create": { "invalid": "Comprueba los datos de la medición e inténtalo de nuevo." diff --git a/messages/fr.json b/messages/fr.json index b2abdab83..70b8c979e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -61,6 +61,9 @@ "signIn": "Se connecter" }, "apiErrors": { + "http": { + "rate_limited": "Trop de tentatives. Attendez quelques minutes puis réessayez." + }, "measurement": { "create": { "invalid": "Vérifiez les données de la mesure et réessayez." diff --git a/messages/it.json b/messages/it.json index d1f8668de..d0ece4eb7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -61,6 +61,9 @@ "signIn": "Accedi" }, "apiErrors": { + "http": { + "rate_limited": "Troppi tentativi. Attendi qualche minuto e riprova." + }, "measurement": { "create": { "invalid": "Controlla i dati della misurazione e riprova." diff --git a/messages/ko.json b/messages/ko.json index b06904477..2f7a66fd2 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -61,6 +61,9 @@ "signIn": "로그인" }, "apiErrors": { + "http": { + "rate_limited": "시도 횟수가 너무 많습니다. 몇 분 후 다시 시도하세요." + }, "measurement": { "create": { "invalid": "측정 내용을 확인하고 다시 시도해 주세요." diff --git a/messages/nl.json b/messages/nl.json index 317568cd6..3da40d54c 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -61,6 +61,9 @@ "signIn": "Aanmelden" }, "apiErrors": { + "http": { + "rate_limited": "Te veel pogingen. Wacht een paar minuten en probeer het opnieuw." + }, "measurement": { "create": { "invalid": "Controleer de gegevens van de meting en probeer het opnieuw." diff --git a/messages/pl.json b/messages/pl.json index 37dfdb6a5..fc61e35f0 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -61,6 +61,9 @@ "signIn": "Zaloguj się" }, "apiErrors": { + "http": { + "rate_limited": "Zbyt wiele prób. Odczekaj kilka minut i spróbuj ponownie." + }, "measurement": { "create": { "invalid": "Sprawdź dane pomiaru i spróbuj ponownie." diff --git a/messages/pt-BR.json b/messages/pt-BR.json index e7667561a..b796c9eea 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -61,6 +61,9 @@ "signIn": "Entrar" }, "apiErrors": { + "http": { + "rate_limited": "Muitas tentativas. Aguarde alguns minutos e tente novamente." + }, "measurement": { "create": { "invalid": "Verifique os dados da medição e tente novamente." diff --git a/messages/ru.json b/messages/ru.json index 659c95666..2cc827849 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -61,6 +61,9 @@ "signIn": "Вход" }, "apiErrors": { + "http": { + "rate_limited": "Слишком много попыток. Подождите несколько минут и попробуйте снова." + }, "measurement": { "create": { "invalid": "Проверьте данные измерения и попробуйте снова." diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index e6d9b833e..b50560a7e 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 84, - "total": 94047, + "total": 94006, "entries": { "app/medications/page-client.tsx": 839, "components/charts/mood-chart.tsx": 1166, @@ -12,7 +12,7 @@ "components/insights/derived/vitals-dashboard.tsx": 1033, "components/insights/health-score-card.tsx": 1023, "components/insights/insights-tab-strip.tsx": 1087, - "components/measurements/measurement-form.tsx": 815, + "components/measurements/measurement-form.tsx": 812, "components/medications/detail/medication-detail-tabs.tsx": 1157, "components/medications/medication-table.tsx": 853, "components/medications/wizard/wizard-payload.ts": 1414, @@ -82,7 +82,7 @@ "lib/search/search-records.ts": 996, "lib/sharing/grants.ts": 806, "lib/support/report.ts": 903, - "lib/telegram-webhook-handlers.ts": 1341, + "lib/telegram-webhook-handlers.ts": 1335, "lib/validations/inbound-documents.ts": 1018, "lib/withings/sync-sleep.ts": 884, "lib/withings/sync.ts": 933, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index e9e3df96f..dc9dbb7cd 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 344, - "total": 111495, + "count": 343, + "total": 111120, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -17,7 +17,7 @@ "app/api/devices/route.ts :: apiHandler(arg)": 226, "app/api/encounters/[id]/route.ts :: apiHandler(arg) #2": 269, "app/api/environment/route.ts :: apiHandler(arg)": 201, - "app/api/export/health-record/route.ts :: apiHandler(arg)": 241, + "app/api/export/health-record/route.ts :: apiHandler(arg)": 240, "app/api/ingest/medication/route.ts :: apiHandler(arg)": 259, "app/api/insights/cards/route.ts :: apiHandler(arg)": 211, "app/api/integrations/status/route.ts :: apiHandler(arg)": 476, @@ -38,8 +38,8 @@ "app/api/record-settings/[family]/route.ts :: apiHandler(arg) #2": 262, "app/api/user/ai-provider/route.ts :: apiHandler(arg) #2": 248, "app/api/withings/callback/route.ts :: apiHandler(arg)": 247, - "app/auth/login/page.tsx :: LoginPage": 569, - "app/auth/register/page.tsx :: RegisterPage": 285, + "app/auth/login/page.tsx :: LoginPage": 561, + "app/auth/register/page.tsx :: RegisterPage": 283, "app/coach/conversations/page.tsx :: CoachConversationsBody": 249, "app/coach/plans/page.tsx :: CoachPlansBody": 216, "app/insights/catalog/page.tsx :: MetricCatalogPage": 204, @@ -64,7 +64,7 @@ "components/admin/user-management-section.tsx :: UserManagementSection": 622, "components/admin/web-push-vapid-section.tsx :: WebPushVapidSection": 217, "components/auth/claim-profile.tsx :: ClaimProfile": 429, - "components/auth/mfa-login-step.tsx :: MfaLoginStep": 217, + "components/auth/mfa-login-step.tsx :: MfaLoginStep": 215, "components/charts/compliance-heatmap.tsx :: ComplianceHeatmap": 325, "components/charts/health-chart.tsx :: HealthChart": 470, "components/charts/medication-compliance-chart.tsx :: MedicationComplianceChart": 543, @@ -73,7 +73,7 @@ "components/charts/trend-card.tsx :: TrendCard": 365, "components/clinician/clinician-view.tsx :: ClinicianView": 223, "components/command-palette/command-palette.tsx :: PaletteBody": 333, - "components/custom-metrics/custom-metric-chart.tsx :: CustomMetricChart": 231, + "components/custom-metrics/custom-metric-chart.tsx :: CustomMetricChart": 228, "components/custom-metrics/custom-metric-detail.tsx :: CustomMetricDetail": 277, "components/custom-metrics/custom-metric-form.tsx :: CustomMetricForm": 214, "components/custom-metrics/custom-metric-history-list.tsx :: CustomMetricHistoryList": 302, @@ -138,7 +138,7 @@ "components/labs/lab-history-list.tsx :: LabHistoryList": 534, "components/labs/ocr-review-dialog.tsx :: OcrReviewDialog": 359, "components/labs/ocr-row-editor.tsx :: OcrRowEditor": 378, - "components/layout/auth-shell.tsx :: AuthShell": 518, + "components/layout/auth-shell.tsx :: AuthShell": 483, "components/layout/bottom-nav.tsx :: BottomNav": 262, "components/layout/capture-picker.tsx :: CapturePicker": 235, "components/layout/sidebar-nav.tsx :: SidebarNav": 356, @@ -146,7 +146,7 @@ "components/links/entity-link-picker.tsx :: EntityLinkPicker": 392, "components/measurement-reminders/vorsorge-dashboard-card.tsx :: VorsorgeDashboardCard": 215, "components/measurement-reminders/vorsorge-section.tsx :: VorsorgeSection": 352, - "components/measurements/measurement-form.tsx :: MeasurementForm": 500, + "components/measurements/measurement-form.tsx :: MeasurementForm": 493, "components/medications/detail/efficacy/efficacy-chart.tsx :: EfficacyChart": 361, "components/medications/detail/medication-detail-tabs.tsx :: MedicationDetailTabs": 767, "components/medications/dose-history-add-dialog.tsx :: LedgerAddDialog": 223, @@ -161,7 +161,7 @@ "components/medications/medication-table.tsx :: MedicationTableRowItem": 377, "components/medications/scheduling/natural-language-extractor.tsx :: NaturalLanguageExtractor": 214, "components/medications/scheduling/schedule-history-timeline.tsx :: EraDialog": 219, - "components/medications/scheduling/schedule-history-timeline.tsx :: ScheduleHistoryTimeline": 256, + "components/medications/scheduling/schedule-history-timeline.tsx :: ScheduleHistoryTimeline": 254, "components/medications/sections/api-tokens-row.tsx :: ApiTokensRow": 296, "components/medications/sections/composition-section.tsx :: CompositionEditor": 362, "components/medications/sections/inventory-dialogs.tsx :: AddInventoryDialog": 238, @@ -232,7 +232,7 @@ "components/timeline/life-event-form.tsx :: LifeEventForm": 281, "components/timeline/timeline-chart.tsx :: ChartBody": 236, "components/timeline/timeline-chronicle.tsx :: TimelineChronicle": 246, - "components/timeline/timeline-view.tsx :: TimelineView": 486, + "components/timeline/timeline-view.tsx :: TimelineView": 478, "components/ui/responsive-sheet.tsx :: ResponsiveSheet": 239, "components/vaccinations/catalog-picker.tsx :: CatalogPicker": 221, "components/vaccinations/custom-vaccine-sheet.tsx :: CustomVaccineSheet": 246, @@ -284,8 +284,8 @@ "lib/google-health/data-points.ts :: forEachDataPointPage > walk": 223, "lib/google-health/sync-core.ts :: upsertGoogleHealthMeasurements": 452, "lib/illness/correlation.ts :: computeIllnessCorrelation": 233, - "lib/import/labs-csv.ts :: importLabsCsv": 307, - "lib/import/labs-csv.ts :: importLabsCsv > flush": 229, + "lib/import/labs-csv.ts :: importLabsCsv": 305, + "lib/import/labs-csv.ts :: importLabsCsv > flush": 227, "lib/insights/comprehensive-aggregator.ts :: buildFromRollups": 217, "lib/insights/comprehensive-generate.ts :: generateComprehensiveInsight": 876, "lib/insights/features.ts :: extractFeatures": 796, @@ -329,7 +329,7 @@ "lib/medications/list-read.ts :: buildMedicationsList": 308, "lib/medications/scheduling/dose-history.ts :: reconstructDoseHistory": 218, "lib/multipart/stream-to-disk.ts :: streamMultipartToDisk": 272, - "lib/notifications/dispatcher.ts :: dispatchNotification": 378, + "lib/notifications/dispatcher.ts :: dispatchNotification": 368, "lib/notifications/senders/apns.ts :: sendViaApns": 299, "lib/notifications/senders/web-push.ts :: sendViaWebPush": 255, "lib/personal-records/pr-detection-worker.ts :: detectPersonalRecordsForUser": 239, @@ -339,12 +339,12 @@ "lib/support/report.ts :: collectSupportReportFacts": 332, "lib/targets/build-response.ts :: buildTargetsResponse": 330, "lib/targets/vitals-builder.ts :: buildVitalTargets": 324, - "lib/telegram-webhook-handlers.ts :: handleCallback": 599, + "lib/telegram-webhook-handlers.ts :: handleCallback": 590, "lib/telegram-webhook-handlers.ts :: handleTextMessage": 337, "lib/withings/sync-activity.ts :: syncUserActivity": 377, "lib/withings/sync-ecg.ts :: syncUserEcg": 202, "lib/withings/sync-sleep.ts :: syncUserSleep": 414, "lib/withings/sync.ts :: syncUserMeasurements": 337, - "proxy.ts :: proxy": 471 + "proxy.ts :: proxy": 467 } } diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 7888fa6f5..7797f40dc 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", "count": 2885, - "total": 9198, + "total": 9193, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -136,7 +136,7 @@ "app/api/auth/codex/device-start/route.ts": 1, "app/api/auth/login/__tests__/native-token.test.ts": 2, "app/api/auth/login/__tests__/route.test.ts": 1, - "app/api/auth/login/route.ts": 5, + "app/api/auth/login/route.ts": 4, "app/api/auth/me/coach-prefs/route.ts": 7, "app/api/auth/me/cycle-prefs/route.ts": 1, "app/api/auth/me/devices/[id]/route.ts": 3, @@ -556,7 +556,7 @@ "app/api/workouts/route.ts": 2, "app/auth/claim/page.tsx": 1, "app/auth/login/page.tsx": 5, - "app/auth/register/page.tsx": 4, + "app/auth/register/page.tsx": 3, "app/c/[token]/__tests__/page.test.tsx": 2, "app/c/[token]/d/[id]/__tests__/route.test.ts": 1, "app/c/[token]/d/[id]/route.ts": 1, @@ -1094,7 +1094,7 @@ "components/layout/__tests__/shared-record-chrome.test.tsx": 1, "components/layout/__tests__/sidebar-nav.test.tsx": 3, "components/layout/account-switcher-menu.tsx": 1, - "components/layout/auth-shell.tsx": 14, + "components/layout/auth-shell.tsx": 13, "components/layout/bottom-nav.tsx": 8, "components/layout/capture-picker.tsx": 5, "components/layout/nav-model.ts": 17, @@ -2885,7 +2885,7 @@ "lib/withings/sync.ts": 6, "lib/withings/webhook-handler.ts": 3, "lib/workouts/list-read.ts": 1, - "proxy.ts": 27, + "proxy.ts": 25, "types/analytics.ts": 8, "types/backups.ts": 1 } diff --git a/src/__tests__/proxy-demo-mode-allowlist.test.ts b/src/__tests__/proxy-demo-mode-allowlist.test.ts index d460c623d..aa6f89497 100644 --- a/src/__tests__/proxy-demo-mode-allowlist.test.ts +++ b/src/__tests__/proxy-demo-mode-allowlist.test.ts @@ -161,6 +161,25 @@ describe("proxy.ts DEMO_MODE mutation allowlist", () => { expect(proxy(makeRequest("/api/search", "DELETE")).status).toBe(403); }); + it("lets a visitor sign out, report page timings and pick a language", () => { + // Each touches only this visitor: their own session, a log line, their + // own language cookie (the locale route leaves the shared account's row + // alone under DEMO_MODE). Refused, sign-out stayed signed in and every + // page load sent the refused locale write and the timings again. + for (const [path, method] of [ + ["/api/auth/logout", "POST"], + ["/api/internal/web-vitals", "POST"], + ["/api/auth/me/locale", "PUT"], + ] as const) { + expect( + proxy(makeRequest(path, method)).status, + `${method} ${path} is blocked in the demo`, + ).not.toBe(403); + } + // The rest of the account's settings stay closed. + expect(proxy(makeRequest("/api/auth/me/timezone", "PUT")).status).toBe(403); + }); + it("still blocks a health-data mutation (POST /api/measurements)", () => { const res = proxy(makeRequest("/api/measurements", "POST")); expect(res.status).toBe(403); diff --git a/src/__tests__/proxy-early-exit-headers.test.ts b/src/__tests__/proxy-early-exit-headers.test.ts index bfa02f8c5..fed0da4d9 100644 --- a/src/__tests__/proxy-early-exit-headers.test.ts +++ b/src/__tests__/proxy-early-exit-headers.test.ts @@ -87,7 +87,7 @@ describe("proxy early exits carry the baseline security headers", () => { it("unauthenticated page redirect to /auth/login", () => { const res = proxy(makeRequest("/dashboard")); - expect(res.headers.get("location")).toMatch(/\/auth\/login$/); + expect(res.headers.get("location")).toMatch(/\/auth\/login(\?|$)/); expectBaselineHeaders(res); }); diff --git a/src/__tests__/proxy-onboarding-redirect.test.ts b/src/__tests__/proxy-onboarding-redirect.test.ts index ee609e893..68ea922fa 100644 --- a/src/__tests__/proxy-onboarding-redirect.test.ts +++ b/src/__tests__/proxy-onboarding-redirect.test.ts @@ -82,7 +82,7 @@ describe("proxy.ts onboarding redirect (v1.4.22 C4)", () => { expect(res.status).toBe(200); expect(res.headers.get("location")).toBeNull(); const anonymous = proxy(makeRequest("/insights")); - expect(anonymous.headers.get("location")).toMatch(/\/auth\/login$/); + expect(anonymous.headers.get("location")).toMatch(/\/auth\/login(\?|$)/); }); it("passes through when hl_onboarding cookie is absent (completed users)", () => { @@ -123,7 +123,7 @@ describe("proxy.ts onboarding redirect (v1.4.22 C4)", () => { // were still in place it would short-circuit through public-path // handling and 200 instead. expect([307, 308]).toContain(res.status); - expect(res.headers.get("location")).toMatch(/\/auth\/login$/); + expect(res.headers.get("location")).toMatch(/\/auth\/login(\?|$)/); }); it("still treats subroutes /onboarding/ as the onboarding surface", () => { diff --git a/src/__tests__/proxy-sign-in-next.test.ts b/src/__tests__/proxy-sign-in-next.test.ts new file mode 100644 index 000000000..d9740c6f4 --- /dev/null +++ b/src/__tests__/proxy-sign-in-next.test.ts @@ -0,0 +1,40 @@ +/** + * The edge sends a visitor without a session to the sign-in page with the + * page they asked for in `next`, so a home-screen shortcut or a notification + * tap still arrives there after signing in. The sign-in page re-checks the + * value (`sanitizeSameOriginPath`); the proxy only ever puts in the + * request's own path and query. + */ +import { describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/process-type", () => ({ + shouldRunWeb: () => true, +})); + +import { proxy } from "../proxy"; + +function signInRedirect(pathAndQuery: string): URL { + const res = proxy(new NextRequest(`http://localhost${pathAndQuery}`)); + expect([307, 308]).toContain(res.status); + return new URL(res.headers.get("location")!, "http://localhost"); +} + +describe("proxy.ts sign-in redirect", () => { + it.each([ + "/measurements?add=WEIGHT&source=shortcut", + "/mood?add=1", + "/medications", + "/?source=pwa", + ])("carries %s to the sign-in page", (target) => { + const location = signInRedirect(target); + expect(location.pathname).toBe("/auth/login"); + expect(location.searchParams.get("next")).toBe(target); + }); + + it("sends the bare start page to a bare sign-in page", () => { + const location = signInRedirect("/"); + expect(location.pathname).toBe("/auth/login"); + expect(location.search).toBe(""); + }); +}); diff --git a/src/app/api/auth/login/__tests__/route.test.ts b/src/app/api/auth/login/__tests__/route.test.ts index 5de021855..0a5462a02 100644 --- a/src/app/api/auth/login/__tests__/route.test.ts +++ b/src/app/api/auth/login/__tests__/route.test.ts @@ -184,3 +184,30 @@ describe("POST /api/auth/login — OIDC_ONLY server-side enforcement", () => { expect(res.status).toBe(401); }); }); + +describe("POST /api/auth/login — per-address limit", () => { + const original = process.env.DEMO_MODE; + afterEach(() => { + if (original === undefined) delete process.env.DEMO_MODE; + else process.env.DEMO_MODE = original; + }); + + function limitAsked(): unknown { + const call = vi + .mocked(checkAuthSurfaceRateLimit) + .mock.calls.find((c) => c[1] === "auth:login"); + return call?.[2]; + } + + it("allows five attempts per address on an ordinary instance", async () => { + delete process.env.DEMO_MODE; + await POST(makeRequest()); + expect(limitAsked()).toBe(5); + }); + + it("allows thirty on a demo, whose one account everybody shares", async () => { + process.env.DEMO_MODE = "true"; + await POST(makeRequest()); + expect(limitAsked()).toBe(30); + }); +}); diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 0f11f9daf..61baff702 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -38,13 +38,13 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } - // v1.4.43 — `checkAuthSurfaceRateLimit` swaps to a tighter - // global bucket when the trust chain is misconfigured; otherwise it - // is byte-equivalent to the previous per-IP `auth:login:{ip}` key. + // Per IP, or one tighter shared bucket when the trust chain is + // misconfigured. A demo publishes its one account, and a class or an + // office behind one address tries it together: 5 locked them out. const rl = await checkAuthSurfaceRateLimit( request, "auth:login", - 5, + process.env.DEMO_MODE === "true" ? 30 : 5, 15 * 60 * 1000, ); const ip = rl.ip ?? "unknown"; diff --git a/src/app/api/auth/me/locale/__tests__/route.test.ts b/src/app/api/auth/me/locale/__tests__/route.test.ts new file mode 100644 index 000000000..a58f5669d --- /dev/null +++ b/src/app/api/auth/me/locale/__tests__/route.test.ts @@ -0,0 +1,73 @@ +/** + * The language write on a demo. + * + * A demo is one published account every visitor signs into, so a visitor's + * language must not become the account's: the route answers as usual and + * sets the visitor's own cookie, but leaves the shared row alone. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual( + "@/lib/api-handler", + ); + return { ...actual, requireActorAuth: vi.fn() }; +}); +vi.mock("@/lib/db", () => ({ prisma: { user: { update: vi.fn() } } })); +vi.mock("next/headers", () => ({ cookies: vi.fn(async () => ({})) })); +vi.mock("@/lib/i18n/locale-cookie", () => ({ setLocaleCookie: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }), + rateLimitHeaders: vi.fn(() => ({})), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +import { PUT } from "../route"; +import { requireActorAuth } from "@/lib/api-handler"; +import { prisma } from "@/lib/db"; +import { setLocaleCookie } from "@/lib/i18n/locale-cookie"; + +const ORIGINAL_DEMO_MODE = process.env.DEMO_MODE; + +function put(locale: string) { + return PUT( + new Request("http://localhost/api/auth/me/locale", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ locale }), + }) as never, + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requireActorAuth).mockResolvedValue({ + user: { id: "demo-account", locale: "en" }, + } as never); +}); + +afterEach(() => { + if (ORIGINAL_DEMO_MODE === undefined) delete process.env.DEMO_MODE; + else process.env.DEMO_MODE = ORIGINAL_DEMO_MODE; +}); + +describe("PUT /api/auth/me/locale", () => { + it("stores the language on the account", async () => { + delete process.env.DEMO_MODE; + const res = await put("de"); + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "demo-account" }, + data: { locale: "de" }, + }); + }); + + it("keeps a demo visitor's language in their own cookie only", async () => { + process.env.DEMO_MODE = "true"; + const res = await put("de"); + expect(res.status).toBe(200); + expect(prisma.user.update).not.toHaveBeenCalled(); + expect(setLocaleCookie).toHaveBeenCalledWith(expect.anything(), "de"); + }); +}); diff --git a/src/app/api/auth/me/locale/route.ts b/src/app/api/auth/me/locale/route.ts index 032b924a2..8893e84b8 100644 --- a/src/app/api/auth/me/locale/route.ts +++ b/src/app/api/auth/me/locale/route.ts @@ -66,8 +66,10 @@ export const PUT = apiHandler(async (request: NextRequest) => { } // Idempotent: skip the write (and the annotation noise) when the column - // already matches — the mount-time backfill fires on every page load. - if (user.locale !== locale) { + // already matches — the mount-time backfill fires on every page load. A + // demo is one account every visitor shares, so there the choice stays in + // this visitor's cookie and never reaches the row the next visitor reads. + if (user.locale !== locale && process.env.DEMO_MODE !== "true") { await prisma.user.update({ where: { id: user.id }, data: { locale }, diff --git a/src/app/auth/login/__tests__/page.browser.test.tsx b/src/app/auth/login/__tests__/page.browser.test.tsx new file mode 100644 index 000000000..c4b163fde --- /dev/null +++ b/src/app/auth/login/__tests__/page.browser.test.tsx @@ -0,0 +1,147 @@ +/** + * The sign-in page, driven by its buttons. + * + * Single sign-on leaves the page for the identity provider and the session + * starts on the way back, so the previous account's offline copies have to + * be gone before the page is left. A sign-in that stays in the app replaces + * the sign-in page in the history, so Back does not show the form again. + * A refusal is said in the reader's language, never in the server's words. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { page } from "vitest/browser"; + +import { + stubFetch, + type FetchStub, +} from "../../../../../tests/browser/fetch-stub"; +import { renderWithApp } from "../../../../../tests/browser/render"; +import { recordHistory } from "../../../../../tests/browser/url"; + +const clearOffline = vi.hoisted(() => vi.fn(async () => {})); +const loadDocument = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/pwa/query-persister", () => ({ + clearOfflineCachesForSessionEnd: clearOffline, +})); +vi.mock("@/lib/navigation/load-document", () => ({ loadDocument })); + +import { MfaLoginStep } from "@/components/auth/mfa-login-step"; + +import LoginPage from "../page"; + +let api: FetchStub; + +beforeEach(() => { + api = stubFetch() + .on("GET", "/api/auth/registration-status", { + data: { registrationEnabled: false, sessionCookieSecure: false }, + }) + .on("GET", "/api/auth/oidc/status", { + data: { enabled: true, buttonLabel: "Example ID", only: false }, + }); +}); + +afterEach(() => { + clearOffline.mockClear(); + loadDocument.mockClear(); +}); + +describe("sign-in page, single sign-on", () => { + it("clears the offline copies before leaving for the identity provider", async () => { + const { screen } = await renderWithApp(, { user: null }); + await screen.getByRole("button", { name: "Example ID" }).click(); + + await expect.poll(() => loadDocument.mock.calls.length).toBe(1); + expect(loadDocument.mock.calls[0][0]).toMatch( + /^\/api\/auth\/oidc\/login\?/, + ); + expect(clearOffline).toHaveBeenCalledTimes(1); + expect(clearOffline.mock.invocationCallOrder[0]).toBeLessThan( + loadDocument.mock.invocationCallOrder[0], + ); + }); +}); + +describe("sign-in page, password", () => { + it("replaces itself with the page asked for", async () => { + api.on("POST", "/api/auth/login", { data: { id: "signed-in" } }); + window.history.replaceState(null, "", "/auth/login?next=%2Fmood%3Fadd%3D1"); + const history = recordHistory(); + const { screen } = await renderWithApp(, { user: null }); + + await screen.getByTestId("login-use-password").click(); + await screen.getByTestId("login-email").fill("someone"); + await page + .elementLocator(document.querySelector("#password")!) + .fill("a long enough passphrase"); + await screen.getByTestId("login-password-submit").click(); + + await expect.poll(() => window.location.pathname).toBe("/mood"); + expect(window.location.search).toBe("?add=1"); + expect(history.pushes()).toEqual([]); + }); +}); + +describe("sign-in refusals", () => { + async function signInWithPassword() { + const { screen } = await renderWithApp(, { user: null }); + await screen.getByTestId("login-use-password").click(); + await screen.getByTestId("login-email").fill("someone"); + await page + .elementLocator(document.querySelector("#password")!) + .fill("a long enough passphrase"); + await screen.getByTestId("login-password-submit").click(); + return screen; + } + + it("says the sign-in failed instead of the server's words", async () => { + api.on("POST", "/api/auth/login", { + status: 401, + error: "Invalid credentials", + meta: { errorCode: "http.unauthenticated" }, + }); + const screen = await signInWithPassword(); + await expect.element(screen.getByText("Login failed")).toBeVisible(); + expect(document.body.textContent).not.toContain("Invalid credentials"); + }); + + it("says to wait when the limit is reached", async () => { + api.on("POST", "/api/auth/login", { + status: 429, + error: "Too many login attempts. Please try again later.", + meta: { errorCode: "http.rate_limited" }, + }); + const screen = await signInWithPassword(); + await expect + .element( + screen.getByText( + "Too many attempts. Wait a few minutes and try again.", + ), + ) + .toBeVisible(); + }); + + it("does the same for the second factor", async () => { + api.on("POST", "/api/auth/mfa/verify", { + status: 401, + error: "Invalid code", + meta: { errorCode: "http.unauthenticated" }, + }); + const { screen } = await renderWithApp( + {}} + onCancel={() => {}} + />, + { user: null }, + ); + await screen.getByTestId("mfa-code-input").fill("123456"); + await screen.getByTestId("mfa-code-submit").click(); + await expect + .element(screen.getByText("Verification failed. Please try again.")) + .toBeVisible(); + expect(document.body.textContent).not.toContain("Invalid code"); + }); +}); diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 49b44b786..e07989fdc 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -25,6 +25,7 @@ import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister"; import { sanitizeSameOriginPath } from "@/lib/url-safety"; +import { localizedApiError } from "@/lib/api/localized-error"; import { formatDate } from "@/lib/format"; import { InsecureTransportBanner, @@ -176,12 +177,14 @@ export default function LoginPage() { staleTime: 60 * 1000, }); - function handleOidcLogin() { + async function handleOidcLogin() { const params = new URLSearchParams({ next: getRedirectTarget() }); if (clientTransport) { params.set("client_proto", clientTransport.protocol); params.set("client_host", clientTransport.host); } + // The session starts on the way back; the last account's copies go first. + await clearOfflineCachesForSessionEnd(); loadDocument(`/api/auth/oidc/login?${params}`); } @@ -226,21 +229,15 @@ export default function LoginPage() { // stale `["auth","me"]`, so the shell refetches it fresh on mount and the // former explicit `invalidateQueries({ queryKey: auth() })` is redundant. queryClient.clear(); - // Belt-and-suspenders to match the session-END wipe (logout / 401-expiry): - // also drop the OFFLINE layers — the IndexedDB query snapshot and the SW - // data/page caches — so a previous account's persisted dashboard never - // lingers on disk for the new session. The restore is already per-account - // bound, but a fresh login on a shared browser should leave nothing of the - // prior account behind. Order is clear → prefetch → navigate: the wipe is - // started here (best-effort, like logout) before the prefetch warms THIS - // user's data, and the persister's 1 s debounce gives the single IDB delete - // ample headroom to land before the new snapshot is written. + // The offline layers too, as at every session end. Started before the + // prefetch; the persister's 1 s debounce lets the delete land before the + // new account's snapshot is written. void clearOfflineCachesForSessionEnd(); const target = getRedirectTarget(); if (target === "/" && isDashboardSnapshotEnabled()) { prefetchDashboardSnapshot(queryClient); } - router.push(target); + router.replace(target); } async function handlePasskeyLogin() { @@ -269,7 +266,7 @@ export default function LoginPage() { // Route rejections carry the envelope error verbatim; everything // else (WebAuthn ceremony failures) keeps the descriptive mapping. if (err instanceof ApiError) { - setError(err.message || t("auth.loginFailed")); + setError(localizedApiError(err, t, "auth.loginFailed")); } else { const { key, params } = describePasskeyError(err); setError(t(key, params)); @@ -313,11 +310,7 @@ export default function LoginPage() { navigateAfterLogin(); } catch (err) { if (noteRefusal(err)) return; - setError( - err instanceof ApiError && err.message - ? err.message - : t("auth.loginFailed"), - ); + setError(localizedApiError(err, t, "auth.loginFailed")); } finally { setLoading(false); } @@ -464,7 +457,7 @@ export default function LoginPage() { {oidcStatus?.enabled && (