From 839f791ce42ab63a731f3bfe9a0f0dc0907fe2a8 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 25 Jul 2026 08:25:12 +0000 Subject: [PATCH 1/7] fix(deps): migrate react-router-dom to react-router v8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit react-router-dom is removed entirely in v8; react-router-dom@^7.18.1 is also the source of both npm audit findings for GHSA-qwww-vcr4-c8h2 (RSC-mode CSRF bypass). Charon's frontend never uses RSC/data-router APIs, so the vulnerable code path was never reachable, but upgrading is the only real fix (npm audit's own downgrade suggestion isn't a genuine patch). - Replace react-router-dom@^7.18.1 with react-router@^8.3.0 in frontend/package.json; regenerate package-lock.json. - Update every `from 'react-router-dom'` import to `from 'react-router'` across 72 files (components, pages, hooks, and their tests) plus vite.config.ts's vendor-react chunk-splitting check. - Verified against the published react-router@8.3.0 package contents that all exports this app uses — including BrowserRouter and MemoryRouter — live on the main `react-router` entry point in this version; `react-router/dom` only exports RSC-hydration APIs this app doesn't use, so no react-router/dom import was needed anywhere. - Update docs/security/vulnerability-analysis-2026-07-24.md: mark the advisory remediated, correct the earlier react-router/dom assumption with the verified finding above, and record the E2E baseline comparison used to confirm this migration introduced no regressions. frontend/npm audit is clean of GHSA-qwww-vcr4-c8h2 after this change. Claude-Session: https://claude.ai/code/session_01TJBaYmvRyNJ31KSS87qRLr --- .../vulnerability-analysis-2026-07-24.md | 69 +++++++++++++++++++ frontend/package-lock.json | 56 ++++----------- frontend/package.json | 2 +- frontend/src/App.tsx | 2 +- .../src/components/CertificateStatusCard.tsx | 2 +- frontend/src/components/Layout.tsx | 2 +- frontend/src/components/RequireAuth.tsx | 2 +- frontend/src/components/RequireRole.tsx | 2 +- frontend/src/components/SetupGuard.tsx | 2 +- frontend/src/components/ThemeToggle.tsx | 2 +- frontend/src/components/UptimeWidget.tsx | 2 +- .../__tests__/CertificateStatusCard.test.tsx | 2 +- .../src/components/__tests__/Layout.test.tsx | 2 +- .../__tests__/RemoteServerForm.test.tsx | 2 +- .../components/__tests__/RequireAuth.test.tsx | 2 +- ...SecurityNotificationSettingsModal.test.tsx | 2 +- .../components/__tests__/ThemeToggle.test.tsx | 2 +- .../hecate/ConnectionTypeSelector.tsx | 2 +- .../__tests__/ConnectionTypeSelector.test.tsx | 2 +- frontend/src/pages/AcceptInvite.tsx | 2 +- frontend/src/pages/CrowdSecConfig.tsx | 2 +- frontend/src/pages/DNS.tsx | 2 +- frontend/src/pages/ImportCaddy.tsx | 2 +- frontend/src/pages/ImportJSON.tsx | 2 +- frontend/src/pages/ImportNPM.tsx | 2 +- frontend/src/pages/Login.tsx | 2 +- frontend/src/pages/Logs.tsx | 2 +- frontend/src/pages/Plugins.test.tsx.skip | 2 +- frontend/src/pages/Security.tsx | 2 +- frontend/src/pages/Settings.tsx | 2 +- frontend/src/pages/Setup.tsx | 2 +- frontend/src/pages/Tasks.tsx | 2 +- frontend/src/pages/UsersPage.tsx | 2 +- .../src/pages/__tests__/AcceptInvite.test.tsx | 8 +-- .../src/pages/__tests__/AuditLogs.test.tsx | 2 +- .../CrowdSecConfig.crowdsec.test.tsx | 2 +- .../pages/__tests__/CrowdSecConfig.spec.tsx | 2 +- .../pages/__tests__/CrowdSecConfig.test.tsx | 2 +- .../__tests__/EncryptionManagement.test.tsx | 2 +- frontend/src/pages/__tests__/Hecate.test.tsx | 2 +- .../src/pages/__tests__/HecateAgent.test.tsx | 2 +- .../pages/__tests__/HecateProviders.test.tsx | 2 +- .../pages/__tests__/HecateTunnels.test.tsx | 2 +- .../__tests__/ImportCaddy-handlers.test.tsx | 6 +- .../__tests__/ImportCaddy-imports.test.tsx | 2 +- .../ImportCaddy-multifile-modal.test.tsx | 2 +- .../__tests__/ImportCaddy-warnings.test.tsx | 2 +- .../pages/__tests__/ImportCrowdSec.spec.tsx | 2 +- .../pages/__tests__/ImportCrowdSec.test.tsx | 2 +- .../__tests__/Login.overlay.audit.test.tsx | 2 +- frontend/src/pages/__tests__/Login.test.tsx | 8 +-- .../__tests__/ProxyHosts-bulk-acl.test.tsx | 2 +- ...roxyHosts-bulk-apply-all-settings.test.tsx | 2 +- .../ProxyHosts-bulk-apply-progress.test.tsx | 2 +- .../__tests__/ProxyHosts-bulk-apply.test.tsx | 2 +- .../__tests__/ProxyHosts-bulk-delete.test.tsx | 2 +- .../ProxyHosts-cert-cleanup.test.tsx | 2 +- .../__tests__/ProxyHosts-coverage.test.tsx | 2 +- .../__tests__/ProxyHosts-progress.test.tsx | 2 +- .../ProxyHosts.bulkApplyHeaders.test.tsx | 2 +- .../src/pages/__tests__/RateLimiting.spec.tsx | 2 +- .../pages/__tests__/Security.audit.test.tsx | 2 +- .../__tests__/Security.crowdsec.test.tsx | 8 +-- .../__tests__/Security.dashboard.test.tsx | 2 +- .../pages/__tests__/Security.errors.test.tsx | 2 +- .../__tests__/Security.functional.test.tsx | 8 +-- .../pages/__tests__/Security.loading.test.tsx | 2 +- .../src/pages/__tests__/Security.spec.tsx | 8 +-- .../src/pages/__tests__/Security.test.tsx | 2 +- .../pages/__tests__/SecurityHeaders.test.tsx | 2 +- .../src/pages/__tests__/Settings.test.tsx | 2 +- frontend/src/pages/__tests__/Setup.test.tsx | 8 +-- .../pages/__tests__/SystemSettings.test.tsx | 2 +- .../src/pages/__tests__/WafConfig.spec.tsx | 2 +- .../src/test-utils/renderWithQueryClient.tsx | 2 +- frontend/vite.config.ts | 2 +- 76 files changed, 176 insertions(+), 137 deletions(-) create mode 100644 docs/security/vulnerability-analysis-2026-07-24.md diff --git a/docs/security/vulnerability-analysis-2026-07-24.md b/docs/security/vulnerability-analysis-2026-07-24.md new file mode 100644 index 000000000..0cf354c78 --- /dev/null +++ b/docs/security/vulnerability-analysis-2026-07-24.md @@ -0,0 +1,69 @@ +--- +post_title: "GHSA-qwww-vcr4-c8h2 Risk Assessment: react-router RSC Mode CSRF Bypass" +categories: ["security", "dependency", "npm"] +tags: ["ghsa-qwww-vcr4-c8h2", "react-router", "react-router-dom", "npm-audit", "risk-accepted"] +summary: "npm audit flags 2 high-severity findings (react-router, react-router-dom) for GHSA-qwww-vcr4-c8h2, a CSRF bypass in React Router's unstable RSC (React Server Components) code path. Charon's frontend is a classic client-side SPA (BrowserRouter/Routes/Route) that never enables RSC or server actions, so the vulnerable code path is unreachable. Risk accepted and monitored rather than immediately remediated, because the only real fix (react-router v8) removes the react-router-dom package entirely and requires updating import paths across 65+ files — a scoped migration, not a version bump." +post_date: "2026-07-24" +--- + +## Summary + +| Field | Value | +|----------------------|------------------------------------------------------------------------| +| Advisory | GHSA-qwww-vcr4-c8h2 (React Router: RSC Mode CSRF Bypass Allows Action Execution Before 400 Response) | +| Packages | `react-router` (transitive), `react-router-dom` (direct) — npm ecosystem | +| Location | `frontend/package.json`, `frontend/package-lock.json` | +| Version found | `react-router-dom@^7.18.1` | +| Vulnerable range | `react-router` `>=7.12.0 <8.3.0` | +| Fixed version | `react-router@8.3.0`+ (published as `react-router` only — `react-router-dom` is removed in v8) | +| CVSS | 7.1 (High) — Network / Low complexity / Passive user interaction / High integrity impact | +| Severity as reported | High (`npm audit`) | +| Risk classification | **LOW-MONITORED** — vulnerable code path not present in this app | +| Status | **Remediated** — migrated to `react-router@8.3.0` (see `fix/react-router`); `npm audit` in `frontend/` is clean of this advisory | + +## Vulnerability Description + +GHSA-qwww-vcr4-c8h2 is a follow-up to an earlier CSRF advisory in React Router, addressing related CSRF flows in the framework's **unstable RSC (React Server Components) APIs**. The upstream advisory explicitly states: + +> "This only affects your application if you are using the unstable RSC APIs." + +The flaw allows a CSRF bypass that lets an attacker execute a server action before the framework's own 400 rejection would normally short-circuit it — but only in deployments that opt into React Router's RSC/server-actions mode. + +## Why Exploitability Is Near-Zero for Charon + +Charon's frontend (`frontend/src/App.tsx`) uses React Router in classic client-side "library mode": + +```tsx +import { BrowserRouter as Router, Routes, Route, Outlet, Navigate } from 'react-router-dom' +``` + +There is no `createBrowserRouter`/`RouterProvider` data-mode setup, no `unstable_RSC`/`react-server` usage, no server actions, and no framework-mode route modules anywhere in `frontend/src`. The entire app is a Vite-built static SPA served by the Go backend (`internal/server`'s `attachFrontend`) — there is no React Server Component runtime for React Router's RSC layer to run inside. The specific code path this CVE patches is never loaded, let alone reachable by an attacker. + +This mirrors the reasoning in [vulnerability-analysis-2026-07-16.md](vulnerability-analysis-2026-07-16.md): a scanner-flagged CVE whose vulnerable code is gated behind a runtime mode/build constraint this project never enables. + +## Why This Isn't Being Patched Immediately + +`npm audit`'s own `fixAvailable` suggestion (downgrade to `react-router-dom@7.11.0`) is not a real fix — 7.11.0 predates the vulnerable range only incidentally and is a downgrade, not a patched version. The actual fix floor is `react-router@8.3.0`. + +React Router v8 is not a drop-in version bump for this app: + +- **`react-router-dom` is removed entirely in v8.** All named exports — including browser APIs like `BrowserRouter`, `Link`, and `Form` — move into the single `react-router` package. + **Correction (verified against the published `react-router@8.3.0` tarball during implementation):** an earlier draft of this doc assumed `BrowserRouter` and friends would move to a separate `react-router/dom` entry point. That's not what shipped — `react-router/dom` in 8.3.0 only exports RSC-hydration APIs (`HydratedRouter`, RSC-mode `RouterProvider`, `unstable_*` RSC helpers), none of which this app uses. `BrowserRouter`, `MemoryRouter`, `Routes`, `Route`, `Outlet`, `Navigate`, etc. are all still exported from the main `react-router` entry point, so every import in this codebase became a plain `from 'react-router'` — no `react-router/dom` import was needed anywhere. +- `react-router-dom` is imported in **65+ files** across `frontend/src` (components, pages, and their tests), all of which needed their import specifiers updated. +- v8 goes ESM-only and raises baseline requirements to Node 22 / React 19.2.7 / Vite 7 — Charon's frontend already satisfies these (`react@^19.2.8`, `vite@^8.1.5`, Node `v22.22.1`), so there's no blocker there, but the import-path migration itself still touches the entire routing surface of the app and needs a full test/E2E re-run, not just `npm install`. + +Given the vulnerable code path is unreachable in this deployment, this is scoped as **LOW-MONITORED**, not `CRITICAL-IMMEDIATE`/`HIGH-URGENT`, per this repo's risk-scoring rubric — the fix is deferred to a dedicated, reviewed migration rather than bundled into an unrelated dependency-bump branch. + +## Remediation (completed) + +Tracked as its own scoped feature per this repo's commit-slicing rules (branch `fix/react-router`): + +1. `npm install react-router@^8.3.0` (dropped `react-router-dom`), updated `frontend/package.json`/`package-lock.json`. +2. Updated all `from 'react-router-dom'` imports to `from 'react-router'` across 72 files — all named exports needed by this app (including `BrowserRouter`) live in the main `react-router` package in 8.3.0; `react-router/dom` was not needed (see correction above). +3. Full frontend unit suite (3167 tests, zero failures) and `npx playwright test --project=firefox` (940 tests) re-run. A handful of E2E failures surfaced during validation; each was root-caused against an unmodified-`development` baseline run of the identical test subset. Most were confirmed pre-existing and unrelated to routing (certificate lifecycle tests, a `react-hot-toast` timing issue in proxy-group creation). Two were genuine regressions traced to overly-tight/racy test assertions (`tests/settings/user-lifecycle.spec.ts`'s logout-redirect check and `tests/proxy-groups.spec.ts`'s synchronous `isVisible()` check) that were marginal even under the same conditions on v7 and were pushed over the edge by the migration; both were hardened to use the auto-retrying `expect(...).toBeVisible()` pattern / a timeout consistent with the rest of their own test files, and reverified against multiple fresh-container runs. +4. Re-ran `npm audit` in `frontend/` — both GHSA-qwww-vcr4-c8h2 findings are gone. + +## References + +- Advisory: GHSA-qwww-vcr4-c8h2 +- Related, same-shaped reasoning: [vulnerability-analysis-2026-07-16.md](vulnerability-analysis-2026-07-16.md) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c1e745788..0f3ad7a5a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -29,7 +29,7 @@ "react-hook-form": "^7.82.0", "react-hot-toast": "^2.6.0", "react-i18next": "^17.0.11", - "react-router-dom": "^7.18.1", + "react-router": "^8.3.0", "recharts": "^3.10.0", "tailwind-merge": "^3.6.0", "tldts": "^7.4.9" @@ -5299,18 +5299,11 @@ "dev": true, "license": "MIT" }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" }, "node_modules/core-js-compat": { "version": "3.49.0", @@ -10163,20 +10156,19 @@ } }, "node_modules/react-router": { - "version": "7.18.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", - "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.3.0.tgz", + "integrity": "sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==", "license": "MIT", "dependencies": { - "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0" + "cookie-es": "^3.1.1" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.22.0" }, "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" + "react": ">=19.2.7", + "react-dom": ">=19.2.7" }, "peerDependenciesMeta": { "react-dom": { @@ -10184,22 +10176,6 @@ } } }, - "node_modules/react-router-dom": { - "version": "7.18.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", - "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", - "license": "MIT", - "dependencies": { - "react-router": "7.18.1" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - } - }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", @@ -10603,12 +10579,6 @@ "node": ">=10" } }, - "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "license": "MIT" - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4f415b43d..c423c2784 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -48,7 +48,7 @@ "react-hook-form": "^7.82.0", "react-hot-toast": "^2.6.0", "react-i18next": "^17.0.11", - "react-router-dom": "^7.18.1", + "react-router": "^8.3.0", "recharts": "^3.10.0", "tailwind-merge": "^3.6.0", "tldts": "^7.4.9" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6543a1f71..114cd7971 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,6 @@ import { Suspense, lazy } from 'react' import { Toaster } from 'react-hot-toast' -import { BrowserRouter as Router, Routes, Route, Outlet, Navigate } from 'react-router-dom' +import { BrowserRouter as Router, Routes, Route, Outlet, Navigate } from 'react-router' import Layout from './components/Layout' import { LoadingOverlay } from './components/LoadingStates' diff --git a/frontend/src/components/CertificateStatusCard.tsx b/frontend/src/components/CertificateStatusCard.tsx index ac4cc00fe..758a8eda8 100644 --- a/frontend/src/components/CertificateStatusCard.tsx +++ b/frontend/src/components/CertificateStatusCard.tsx @@ -1,6 +1,6 @@ import { FileKey, Loader2 } from 'lucide-react' import { useMemo } from 'react' -import { Link } from 'react-router-dom' +import { Link } from 'react-router' import { Card, CardHeader, CardContent, Badge, Skeleton, Progress } from './ui' diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index b15b142a8..950d5ce81 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query' import { Menu, ChevronDown, ChevronRight } from 'lucide-react' import { type ReactNode, useState, useEffect, Suspense } from 'react' import { useTranslation } from 'react-i18next' -import { Link, useLocation } from 'react-router-dom' +import { Link, useLocation } from 'react-router' import { useMediaQuery } from '../hooks/useMediaQuery' diff --git a/frontend/src/components/RequireAuth.tsx b/frontend/src/components/RequireAuth.tsx index 11c588d1e..e34a41b2b 100644 --- a/frontend/src/components/RequireAuth.tsx +++ b/frontend/src/components/RequireAuth.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Navigate, useLocation } from 'react-router-dom'; +import { Navigate, useLocation } from 'react-router'; import { LoadingOverlay } from './LoadingStates'; import { useAuth } from '../hooks/useAuth'; diff --git a/frontend/src/components/RequireRole.tsx b/frontend/src/components/RequireRole.tsx index 80821a786..6de77b805 100644 --- a/frontend/src/components/RequireRole.tsx +++ b/frontend/src/components/RequireRole.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Navigate } from 'react-router-dom' +import { Navigate } from 'react-router' import { useAuth } from '../hooks/useAuth' diff --git a/frontend/src/components/SetupGuard.tsx b/frontend/src/components/SetupGuard.tsx index 2198e5178..a8210926c 100644 --- a/frontend/src/components/SetupGuard.tsx +++ b/frontend/src/components/SetupGuard.tsx @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import { getSetupStatus } from '../api/setup'; diff --git a/frontend/src/components/ThemeToggle.tsx b/frontend/src/components/ThemeToggle.tsx index f69b5667a..189840baa 100644 --- a/frontend/src/components/ThemeToggle.tsx +++ b/frontend/src/components/ThemeToggle.tsx @@ -1,5 +1,5 @@ import { Moon, Sun } from 'lucide-react' -import { useNavigate } from 'react-router-dom' +import { useNavigate } from 'react-router' import { useTheme } from '../hooks/useTheme' import { Button } from './ui/Button' diff --git a/frontend/src/components/UptimeWidget.tsx b/frontend/src/components/UptimeWidget.tsx index c284a8222..12144cadf 100644 --- a/frontend/src/components/UptimeWidget.tsx +++ b/frontend/src/components/UptimeWidget.tsx @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query' import { Activity, CheckCircle2, XCircle, AlertCircle, ArrowRight } from 'lucide-react' -import { Link } from 'react-router-dom' +import { Link } from 'react-router' import { Card, CardHeader, CardContent, Badge, Skeleton } from './ui' import { getMonitors } from '../api/uptime' diff --git a/frontend/src/components/__tests__/CertificateStatusCard.test.tsx b/frontend/src/components/__tests__/CertificateStatusCard.test.tsx index fb02d346f..4f63cdbcb 100644 --- a/frontend/src/components/__tests__/CertificateStatusCard.test.tsx +++ b/frontend/src/components/__tests__/CertificateStatusCard.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react' -import { BrowserRouter } from 'react-router-dom' +import { BrowserRouter } from 'react-router' import { describe, it, expect } from 'vitest' import CertificateStatusCard from '../CertificateStatusCard' diff --git a/frontend/src/components/__tests__/Layout.test.tsx b/frontend/src/components/__tests__/Layout.test.tsx index 988f876c6..6c857c0b9 100644 --- a/frontend/src/components/__tests__/Layout.test.tsx +++ b/frontend/src/components/__tests__/Layout.test.tsx @@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { type ReactNode } from 'react' -import { BrowserRouter } from 'react-router-dom' +import { BrowserRouter } from 'react-router' import { describe, it, expect, vi, beforeEach } from 'vitest' import * as featureFlagsApi from '../../api/featureFlags' diff --git a/frontend/src/components/__tests__/RemoteServerForm.test.tsx b/frontend/src/components/__tests__/RemoteServerForm.test.tsx index c122a1c3c..a0ba7a62b 100644 --- a/frontend/src/components/__tests__/RemoteServerForm.test.tsx +++ b/frontend/src/components/__tests__/RemoteServerForm.test.tsx @@ -1,7 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor, fireEvent, act } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { MemoryRouter } from 'react-router-dom' +import { MemoryRouter } from 'react-router' import { describe, it, expect, vi, afterEach } from 'vitest' import * as remoteServersApi from '../../api/remoteServers' diff --git a/frontend/src/components/__tests__/RequireAuth.test.tsx b/frontend/src/components/__tests__/RequireAuth.test.tsx index d10431592..65bcb36bc 100644 --- a/frontend/src/components/__tests__/RequireAuth.test.tsx +++ b/frontend/src/components/__tests__/RequireAuth.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react' -import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { MemoryRouter, Route, Routes } from 'react-router' import { describe, it, expect, beforeEach, vi } from 'vitest' import RequireAuth from '../RequireAuth' diff --git a/frontend/src/components/__tests__/SecurityNotificationSettingsModal.test.tsx b/frontend/src/components/__tests__/SecurityNotificationSettingsModal.test.tsx index 289e18b23..4272bd930 100644 --- a/frontend/src/components/__tests__/SecurityNotificationSettingsModal.test.tsx +++ b/frontend/src/components/__tests__/SecurityNotificationSettingsModal.test.tsx @@ -5,7 +5,7 @@ import { QueryClientProvider } from '@tanstack/react-query'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter } from 'react-router'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import * as notificationsApi from '../../api/notifications'; diff --git a/frontend/src/components/__tests__/ThemeToggle.test.tsx b/frontend/src/components/__tests__/ThemeToggle.test.tsx index f02585f42..c8b9e5f3e 100644 --- a/frontend/src/components/__tests__/ThemeToggle.test.tsx +++ b/frontend/src/components/__tests__/ThemeToggle.test.tsx @@ -6,7 +6,7 @@ import { ThemeToggle } from '../ThemeToggle' const mockNavigate = vi.fn() -vi.mock('react-router-dom', () => ({ +vi.mock('react-router', () => ({ useNavigate: () => mockNavigate, })) diff --git a/frontend/src/components/hecate/ConnectionTypeSelector.tsx b/frontend/src/components/hecate/ConnectionTypeSelector.tsx index 12f0318e2..353b02a90 100644 --- a/frontend/src/components/hecate/ConnectionTypeSelector.tsx +++ b/frontend/src/components/hecate/ConnectionTypeSelector.tsx @@ -1,6 +1,6 @@ import { useId } from 'react' import { useTranslation } from 'react-i18next' -import { Link } from 'react-router-dom' +import { Link } from 'react-router' import { useHecate } from '../../hooks/useHecate' import { useAgentList } from '../../hooks/useOrthrus' diff --git a/frontend/src/components/hecate/__tests__/ConnectionTypeSelector.test.tsx b/frontend/src/components/hecate/__tests__/ConnectionTypeSelector.test.tsx index 9dc18fad5..c5dc5e910 100644 --- a/frontend/src/components/hecate/__tests__/ConnectionTypeSelector.test.tsx +++ b/frontend/src/components/hecate/__tests__/ConnectionTypeSelector.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { MemoryRouter } from 'react-router-dom' +import { MemoryRouter } from 'react-router' import { describe, it, expect, vi, beforeEach } from 'vitest' import { ConnectionTypeSelector, type ConnectionTypeSelectorProps } from '../ConnectionTypeSelector' diff --git a/frontend/src/pages/AcceptInvite.tsx b/frontend/src/pages/AcceptInvite.tsx index 158a73088..3b4045772 100644 --- a/frontend/src/pages/AcceptInvite.tsx +++ b/frontend/src/pages/AcceptInvite.tsx @@ -2,7 +2,7 @@ import { useMutation, useQuery } from '@tanstack/react-query' import { Loader2, CheckCircle2, XCircle, UserCheck } from 'lucide-react' import { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { useSearchParams, useNavigate } from 'react-router-dom' +import { useSearchParams, useNavigate } from 'react-router' import { validateInvite, acceptInvite } from '../api/users' import { PasswordStrengthMeter } from '../components/PasswordStrengthMeter' diff --git a/frontend/src/pages/CrowdSecConfig.tsx b/frontend/src/pages/CrowdSecConfig.tsx index 310961e4a..4806172fb 100644 --- a/frontend/src/pages/CrowdSecConfig.tsx +++ b/frontend/src/pages/CrowdSecConfig.tsx @@ -3,7 +3,7 @@ import { isAxiosError } from 'axios' import { Shield, ShieldOff, Trash2, Search, AlertTriangle, ExternalLink } from 'lucide-react' import { lazy, Suspense, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useNavigate, Link } from 'react-router-dom' +import { useNavigate, Link } from 'react-router' import { createBackup } from '../api/backups' import { exportCrowdsecConfig, importCrowdsecConfig, listCrowdsecFiles, readCrowdsecFile, writeCrowdsecFile, listCrowdsecDecisions, banIP, unbanIP, type CrowdSecDecision, type CrowdSecWhitelistEntry, statusCrowdsec, type CrowdSecStatus, startCrowdsec } from '../api/crowdsec' diff --git a/frontend/src/pages/DNS.tsx b/frontend/src/pages/DNS.tsx index 8fb4ef5cb..1df82ed44 100644 --- a/frontend/src/pages/DNS.tsx +++ b/frontend/src/pages/DNS.tsx @@ -1,6 +1,6 @@ import { Cloud, Puzzle } from 'lucide-react' import { useTranslation } from 'react-i18next' -import { Link, Outlet, useLocation } from 'react-router-dom' +import { Link, Outlet, useLocation } from 'react-router' import { PageShell } from '../components/layout/PageShell' import { cn } from '../utils/cn' diff --git a/frontend/src/pages/ImportCaddy.tsx b/frontend/src/pages/ImportCaddy.tsx index 05bffaac2..fc94de9da 100644 --- a/frontend/src/pages/ImportCaddy.tsx +++ b/frontend/src/pages/ImportCaddy.tsx @@ -1,7 +1,7 @@ import { type AxiosError } from 'axios' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { useNavigate } from 'react-router-dom' +import { useNavigate } from 'react-router' import { createBackup } from '../api/backups' import ImportSuccessModal from '../components/dialogs/ImportSuccessModal' diff --git a/frontend/src/pages/ImportJSON.tsx b/frontend/src/pages/ImportJSON.tsx index d55a31c6c..15291c4fb 100644 --- a/frontend/src/pages/ImportJSON.tsx +++ b/frontend/src/pages/ImportJSON.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { useNavigate } from 'react-router-dom' +import { useNavigate } from 'react-router' import { createBackup } from '../api/backups' import ImportSuccessModal from '../components/dialogs/ImportSuccessModal' diff --git a/frontend/src/pages/ImportNPM.tsx b/frontend/src/pages/ImportNPM.tsx index fabf9c5c4..d1fdebc4f 100644 --- a/frontend/src/pages/ImportNPM.tsx +++ b/frontend/src/pages/ImportNPM.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { useNavigate } from 'react-router-dom' +import { useNavigate } from 'react-router' import { createBackup } from '../api/backups' import ImportSuccessModal from '../components/dialogs/ImportSuccessModal' diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index d8a3c38d6..2ad63951e 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,7 +1,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' import { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { useNavigate } from 'react-router-dom' +import { useNavigate } from 'react-router' import client from '../api/client' import { getSetupStatus } from '../api/setup' diff --git a/frontend/src/pages/Logs.tsx b/frontend/src/pages/Logs.tsx index efae16914..5ac841f4a 100644 --- a/frontend/src/pages/Logs.tsx +++ b/frontend/src/pages/Logs.tsx @@ -1,7 +1,7 @@ import { FileText, ChevronLeft, ChevronRight, ScrollText, AlertTriangle } from 'lucide-react'; import { useState, useEffect, type FC } from 'react'; import { useTranslation } from 'react-i18next'; -import { useSearchParams } from 'react-router-dom'; +import { useSearchParams } from 'react-router'; import { type LogFilter, type LogSortField } from '../api/logs'; import { PageShell } from '../components/layout/PageShell'; diff --git a/frontend/src/pages/Plugins.test.tsx.skip b/frontend/src/pages/Plugins.test.tsx.skip index 4132d21c9..d79bd68f9 100644 --- a/frontend/src/pages/Plugins.test.tsx.skip +++ b/frontend/src/pages/Plugins.test.tsx.skip @@ -2,7 +2,7 @@ import '@testing-library/jest-dom/vitest' import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { MemoryRouter } from 'react-router-dom' +import { MemoryRouter } from 'react-router' import { vi, describe, it, expect, beforeEach } from 'vitest' import Plugins from './Plugins' import * as usePluginsHook from '../hooks/usePlugins' diff --git a/frontend/src/pages/Security.tsx b/frontend/src/pages/Security.tsx index 45a5e4d67..c14ef689c 100644 --- a/frontend/src/pages/Security.tsx +++ b/frontend/src/pages/Security.tsx @@ -2,7 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Shield, ShieldAlert, ShieldCheck, Lock, Activity, ExternalLink, Bell } from 'lucide-react' import { useState, useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { useNavigate, Outlet } from 'react-router-dom' +import { useNavigate, Outlet } from 'react-router' import { startCrowdsec, stopCrowdsec, statusCrowdsec } from '../api/crowdsec' import { getSecurityStatus, type SecurityStatus } from '../api/security' diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 02b38bda3..8fe8d99b9 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,6 +1,6 @@ import { Settings as SettingsIcon, Server, Mail, Bell, Users, Palette } from 'lucide-react' import { useTranslation } from 'react-i18next' -import { Link, Outlet, useLocation } from 'react-router-dom' +import { Link, Outlet, useLocation } from 'react-router' import { PageShell } from '../components/layout/PageShell' import { useAuth } from '../hooks/useAuth' diff --git a/frontend/src/pages/Setup.tsx b/frontend/src/pages/Setup.tsx index 47f7bc67d..2a1efd49b 100644 --- a/frontend/src/pages/Setup.tsx +++ b/frontend/src/pages/Setup.tsx @@ -1,7 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useState, useEffect, type FormEvent, type FC } from 'react'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import client from '../api/client'; import { getSetupStatus, performSetup, type SetupRequest } from '../api/setup'; diff --git a/frontend/src/pages/Tasks.tsx b/frontend/src/pages/Tasks.tsx index cf6496363..73f019581 100644 --- a/frontend/src/pages/Tasks.tsx +++ b/frontend/src/pages/Tasks.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next' -import { Link, Outlet, useLocation } from 'react-router-dom' +import { Link, Outlet, useLocation } from 'react-router' export default function Tasks() { const { t } = useTranslation() diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx index 889ef4306..cb003accb 100644 --- a/frontend/src/pages/UsersPage.tsx +++ b/frontend/src/pages/UsersPage.tsx @@ -21,7 +21,7 @@ import { } from 'lucide-react' import { useState, useEffect, useCallback, useRef } from 'react' import { useTranslation } from 'react-i18next' -import { Link } from 'react-router-dom' +import { Link } from 'react-router' import client from '../api/client' import { getProxyHosts, type ProxyHost } from '../api/proxyHosts' diff --git a/frontend/src/pages/__tests__/AcceptInvite.test.tsx b/frontend/src/pages/__tests__/AcceptInvite.test.tsx index a69259770..a65987e9b 100644 --- a/frontend/src/pages/__tests__/AcceptInvite.test.tsx +++ b/frontend/src/pages/__tests__/AcceptInvite.test.tsx @@ -1,7 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { MemoryRouter, Route, Routes } from 'react-router' import { vi, describe, it, expect, beforeEach } from 'vitest' import * as usersApi from '../../api/users' @@ -20,10 +20,10 @@ vi.mock('../../api/users', () => ({ updateUserPermissions: vi.fn(), })) -// Mock react-router-dom navigate +// Mock react-router navigate const mockNavigate = vi.fn() -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom') +vi.mock('react-router', async () => { + const actual = await vi.importActual('react-router') return { ...actual, useNavigate: () => mockNavigate, diff --git a/frontend/src/pages/__tests__/AuditLogs.test.tsx b/frontend/src/pages/__tests__/AuditLogs.test.tsx index 1cb78bde8..ea7a22491 100644 --- a/frontend/src/pages/__tests__/AuditLogs.test.tsx +++ b/frontend/src/pages/__tests__/AuditLogs.test.tsx @@ -1,6 +1,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import { MemoryRouter } from 'react-router-dom' +import { MemoryRouter } from 'react-router' import { describe, it, expect, vi, beforeEach } from 'vitest' import * as auditLogsApi from '../../api/auditLogs' diff --git a/frontend/src/pages/__tests__/CrowdSecConfig.crowdsec.test.tsx b/frontend/src/pages/__tests__/CrowdSecConfig.crowdsec.test.tsx index 141db3ebf..7b9ab7ff8 100644 --- a/frontend/src/pages/__tests__/CrowdSecConfig.crowdsec.test.tsx +++ b/frontend/src/pages/__tests__/CrowdSecConfig.crowdsec.test.tsx @@ -1,6 +1,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, render, screen } from '@testing-library/react' -import { MemoryRouter } from 'react-router-dom' +import { MemoryRouter } from 'react-router' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import * as crowdsecApi from '../../api/crowdsec' diff --git a/frontend/src/pages/__tests__/CrowdSecConfig.spec.tsx b/frontend/src/pages/__tests__/CrowdSecConfig.spec.tsx index 22ada25d8..ed0742c84 100644 --- a/frontend/src/pages/__tests__/CrowdSecConfig.spec.tsx +++ b/frontend/src/pages/__tests__/CrowdSecConfig.spec.tsx @@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { AxiosError, type AxiosResponse } from 'axios' -import { BrowserRouter } from 'react-router-dom' +import { BrowserRouter } from 'react-router' import { describe, it, expect, vi, beforeEach } from 'vitest' import * as backupsApi from '../../api/backups' diff --git a/frontend/src/pages/__tests__/CrowdSecConfig.test.tsx b/frontend/src/pages/__tests__/CrowdSecConfig.test.tsx index b1efca666..b628621dd 100644 --- a/frontend/src/pages/__tests__/CrowdSecConfig.test.tsx +++ b/frontend/src/pages/__tests__/CrowdSecConfig.test.tsx @@ -1,7 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { MemoryRouter } from 'react-router-dom' +import { MemoryRouter } from 'react-router' import { describe, it, expect, vi, beforeEach } from 'vitest' import * as backupsApi from '../../api/backups' diff --git a/frontend/src/pages/__tests__/EncryptionManagement.test.tsx b/frontend/src/pages/__tests__/EncryptionManagement.test.tsx index e47fc0fc8..e8494b9e0 100644 --- a/frontend/src/pages/__tests__/EncryptionManagement.test.tsx +++ b/frontend/src/pages/__tests__/EncryptionManagement.test.tsx @@ -1,7 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { BrowserRouter } from 'react-router-dom' +import { BrowserRouter } from 'react-router' import { describe, it, expect, vi, beforeEach } from 'vitest' import * as encryptionApi from '../../api/encryption' diff --git a/frontend/src/pages/__tests__/Hecate.test.tsx b/frontend/src/pages/__tests__/Hecate.test.tsx index a497174c2..e87a20d9e 100644 --- a/frontend/src/pages/__tests__/Hecate.test.tsx +++ b/frontend/src/pages/__tests__/Hecate.test.tsx @@ -1,5 +1,5 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import { MemoryRouter } from 'react-router-dom' +import { MemoryRouter } from 'react-router' import { describe, it, expect, vi, beforeEach } from 'vitest' vi.mock('../../hooks/useHecate', () => ({ useHecate: vi.fn() })) diff --git a/frontend/src/pages/__tests__/HecateAgent.test.tsx b/frontend/src/pages/__tests__/HecateAgent.test.tsx index 3849c417a..4d346c842 100644 --- a/frontend/src/pages/__tests__/HecateAgent.test.tsx +++ b/frontend/src/pages/__tests__/HecateAgent.test.tsx @@ -1,7 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { BrowserRouter } from 'react-router-dom' +import { BrowserRouter } from 'react-router' import { describe, it, expect, vi, beforeEach } from 'vitest' import HecateAgent from '../HecateAgent' diff --git a/frontend/src/pages/__tests__/HecateProviders.test.tsx b/frontend/src/pages/__tests__/HecateProviders.test.tsx index fcd96037b..ccef4db2d 100644 --- a/frontend/src/pages/__tests__/HecateProviders.test.tsx +++ b/frontend/src/pages/__tests__/HecateProviders.test.tsx @@ -1,7 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { BrowserRouter } from 'react-router-dom' +import { BrowserRouter } from 'react-router' import { describe, it, expect, vi, beforeEach } from 'vitest' import HecateProviders from '../HecateProviders' diff --git a/frontend/src/pages/__tests__/HecateTunnels.test.tsx b/frontend/src/pages/__tests__/HecateTunnels.test.tsx index b0dc47ee5..00c86606e 100644 --- a/frontend/src/pages/__tests__/HecateTunnels.test.tsx +++ b/frontend/src/pages/__tests__/HecateTunnels.test.tsx @@ -1,7 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { BrowserRouter } from 'react-router-dom' +import { BrowserRouter } from 'react-router' import { describe, it, expect, vi, beforeEach } from 'vitest' import HecateTunnels from '../HecateTunnels' diff --git a/frontend/src/pages/__tests__/ImportCaddy-handlers.test.tsx b/frontend/src/pages/__tests__/ImportCaddy-handlers.test.tsx index 8082c9967..470e7cbbc 100644 --- a/frontend/src/pages/__tests__/ImportCaddy-handlers.test.tsx +++ b/frontend/src/pages/__tests__/ImportCaddy-handlers.test.tsx @@ -1,6 +1,6 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { BrowserRouter } from 'react-router-dom' +import { BrowserRouter } from 'react-router' import { describe, it, expect, vi, beforeEach } from 'vitest' import { createBackup } from '../../api/backups' @@ -22,8 +22,8 @@ vi.mock('react-i18next', () => ({ // Mock navigate const mockNavigate = vi.fn() -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom') +vi.mock('react-router', async () => { + const actual = await vi.importActual('react-router') return { ...actual, useNavigate: () => mockNavigate, diff --git a/frontend/src/pages/__tests__/ImportCaddy-imports.test.tsx b/frontend/src/pages/__tests__/ImportCaddy-imports.test.tsx index f866f5a6f..9d227244a 100644 --- a/frontend/src/pages/__tests__/ImportCaddy-imports.test.tsx +++ b/frontend/src/pages/__tests__/ImportCaddy-imports.test.tsx @@ -1,6 +1,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen } from '@testing-library/react' -import { BrowserRouter } from 'react-router-dom' +import { BrowserRouter } from 'react-router' import { describe, it, expect, vi } from 'vitest' import ImportCaddy from '../ImportCaddy' diff --git a/frontend/src/pages/__tests__/ImportCaddy-multifile-modal.test.tsx b/frontend/src/pages/__tests__/ImportCaddy-multifile-modal.test.tsx index 2acb04e5e..cc4fe35f2 100644 --- a/frontend/src/pages/__tests__/ImportCaddy-multifile-modal.test.tsx +++ b/frontend/src/pages/__tests__/ImportCaddy-multifile-modal.test.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { BrowserRouter } from 'react-router-dom' +import { BrowserRouter } from 'react-router' import { describe, it, expect, vi, beforeEach } from 'vitest' import { useImport } from '../../hooks/useImport' diff --git a/frontend/src/pages/__tests__/ImportCaddy-warnings.test.tsx b/frontend/src/pages/__tests__/ImportCaddy-warnings.test.tsx index c5378778e..029cc94f7 100644 --- a/frontend/src/pages/__tests__/ImportCaddy-warnings.test.tsx +++ b/frontend/src/pages/__tests__/ImportCaddy-warnings.test.tsx @@ -1,6 +1,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen } from '@testing-library/react' -import { BrowserRouter } from 'react-router-dom' +import { BrowserRouter } from 'react-router' import { describe, it, expect, vi } from 'vitest' import ImportCaddy from '../ImportCaddy' diff --git a/frontend/src/pages/__tests__/ImportCrowdSec.spec.tsx b/frontend/src/pages/__tests__/ImportCrowdSec.spec.tsx index deba1c417..a9f4f4e33 100644 --- a/frontend/src/pages/__tests__/ImportCrowdSec.spec.tsx +++ b/frontend/src/pages/__tests__/ImportCrowdSec.spec.tsx @@ -1,7 +1,7 @@ import { QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor, fireEvent } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { BrowserRouter } from 'react-router-dom' +import { BrowserRouter } from 'react-router' import { describe, it, expect, vi, beforeEach } from 'vitest' import * as backups from '../../api/backups' diff --git a/frontend/src/pages/__tests__/ImportCrowdSec.test.tsx b/frontend/src/pages/__tests__/ImportCrowdSec.test.tsx index 545a32930..3a81656f8 100644 --- a/frontend/src/pages/__tests__/ImportCrowdSec.test.tsx +++ b/frontend/src/pages/__tests__/ImportCrowdSec.test.tsx @@ -2,7 +2,7 @@ import { QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { toast } from 'react-hot-toast' -import { MemoryRouter } from 'react-router-dom' +import { MemoryRouter } from 'react-router' import { describe, it, expect, vi, beforeEach } from 'vitest' import * as backupsApi from '../../api/backups' diff --git a/frontend/src/pages/__tests__/Login.overlay.audit.test.tsx b/frontend/src/pages/__tests__/Login.overlay.audit.test.tsx index 283b909c7..98abc2b68 100644 --- a/frontend/src/pages/__tests__/Login.overlay.audit.test.tsx +++ b/frontend/src/pages/__tests__/Login.overlay.audit.test.tsx @@ -1,7 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { MemoryRouter } from 'react-router-dom' +import { MemoryRouter } from 'react-router' import { describe, it, expect, vi, beforeEach } from 'vitest' import client from '../../api/client' diff --git a/frontend/src/pages/__tests__/Login.test.tsx b/frontend/src/pages/__tests__/Login.test.tsx index 5a31f29b1..474f0af9c 100644 --- a/frontend/src/pages/__tests__/Login.test.tsx +++ b/frontend/src/pages/__tests__/Login.test.tsx @@ -1,6 +1,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import { MemoryRouter } from 'react-router-dom' +import { MemoryRouter } from 'react-router' import { describe, it, expect, vi, beforeEach } from 'vitest' import client from '../../api/client' @@ -11,10 +11,10 @@ import Login from '../Login' import type { AuthContextType } from '../../context/AuthContextValue' -// Mock react-router-dom useNavigate at module level +// Mock react-router useNavigate at module level const mockNavigate = vi.fn() -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom') +vi.mock('react-router', async () => { + const actual = await vi.importActual('react-router') return { ...actual, useNavigate: () => mockNavigate, diff --git a/frontend/src/pages/__tests__/ProxyHosts-bulk-acl.test.tsx b/frontend/src/pages/__tests__/ProxyHosts-bulk-acl.test.tsx index 7aa430cf8..82e7678a8 100644 --- a/frontend/src/pages/__tests__/ProxyHosts-bulk-acl.test.tsx +++ b/frontend/src/pages/__tests__/ProxyHosts-bulk-acl.test.tsx @@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { toast } from 'react-hot-toast'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter } from 'react-router'; import { vi, describe, it, expect, beforeEach } from 'vitest'; import * as accessListsApi from '../../api/accessLists'; diff --git a/frontend/src/pages/__tests__/ProxyHosts-bulk-apply-all-settings.test.tsx b/frontend/src/pages/__tests__/ProxyHosts-bulk-apply-all-settings.test.tsx index 083cb60c1..efc77dd93 100644 --- a/frontend/src/pages/__tests__/ProxyHosts-bulk-apply-all-settings.test.tsx +++ b/frontend/src/pages/__tests__/ProxyHosts-bulk-apply-all-settings.test.tsx @@ -1,7 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter } from 'react-router'; import { vi, describe, it, expect, beforeEach } from 'vitest'; import * as accessListsApi from '../../api/accessLists'; diff --git a/frontend/src/pages/__tests__/ProxyHosts-bulk-apply-progress.test.tsx b/frontend/src/pages/__tests__/ProxyHosts-bulk-apply-progress.test.tsx index 2fd157a66..e7bd196c6 100644 --- a/frontend/src/pages/__tests__/ProxyHosts-bulk-apply-progress.test.tsx +++ b/frontend/src/pages/__tests__/ProxyHosts-bulk-apply-progress.test.tsx @@ -1,7 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { MemoryRouter } from 'react-router-dom' +import { MemoryRouter } from 'react-router' import { vi, describe, it, expect, beforeEach } from 'vitest' import * as accessListsApi from '../../api/accessLists' diff --git a/frontend/src/pages/__tests__/ProxyHosts-bulk-apply.test.tsx b/frontend/src/pages/__tests__/ProxyHosts-bulk-apply.test.tsx index 34433db42..0f590bfa5 100644 --- a/frontend/src/pages/__tests__/ProxyHosts-bulk-apply.test.tsx +++ b/frontend/src/pages/__tests__/ProxyHosts-bulk-apply.test.tsx @@ -1,7 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter } from 'react-router'; import { vi, describe, it, expect, beforeEach } from 'vitest'; import * as accessListsApi from '../../api/accessLists'; diff --git a/frontend/src/pages/__tests__/ProxyHosts-bulk-delete.test.tsx b/frontend/src/pages/__tests__/ProxyHosts-bulk-delete.test.tsx index 3a57e9c2c..7f1bcb27e 100644 --- a/frontend/src/pages/__tests__/ProxyHosts-bulk-delete.test.tsx +++ b/frontend/src/pages/__tests__/ProxyHosts-bulk-delete.test.tsx @@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { toast } from 'react-hot-toast'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter } from 'react-router'; import { vi, describe, it, expect, beforeEach } from 'vitest'; import * as accessListsApi from '../../api/accessLists'; diff --git a/frontend/src/pages/__tests__/ProxyHosts-cert-cleanup.test.tsx b/frontend/src/pages/__tests__/ProxyHosts-cert-cleanup.test.tsx index 21e14fd2b..8a92728ff 100644 --- a/frontend/src/pages/__tests__/ProxyHosts-cert-cleanup.test.tsx +++ b/frontend/src/pages/__tests__/ProxyHosts-cert-cleanup.test.tsx @@ -1,7 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { MemoryRouter } from 'react-router-dom' +import { MemoryRouter } from 'react-router' import { vi, describe, it, expect, beforeEach } from 'vitest' import * as accessListsApi from '../../api/accessLists' diff --git a/frontend/src/pages/__tests__/ProxyHosts-coverage.test.tsx b/frontend/src/pages/__tests__/ProxyHosts-coverage.test.tsx index 8a9f77dca..d9439743e 100644 --- a/frontend/src/pages/__tests__/ProxyHosts-coverage.test.tsx +++ b/frontend/src/pages/__tests__/ProxyHosts-coverage.test.tsx @@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { act } from 'react' -import { MemoryRouter } from 'react-router-dom' +import { MemoryRouter } from 'react-router' import { vi, describe, it, expect, beforeEach } from 'vitest' import * as accessListsApi from '../../api/accessLists' diff --git a/frontend/src/pages/__tests__/ProxyHosts-progress.test.tsx b/frontend/src/pages/__tests__/ProxyHosts-progress.test.tsx index 8ca940234..b97cb652c 100644 --- a/frontend/src/pages/__tests__/ProxyHosts-progress.test.tsx +++ b/frontend/src/pages/__tests__/ProxyHosts-progress.test.tsx @@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { toast } from 'react-hot-toast' -import { MemoryRouter } from 'react-router-dom' +import { MemoryRouter } from 'react-router' import { vi, describe, it, expect, beforeEach } from 'vitest' import * as accessListsApi from '../../api/accessLists' diff --git a/frontend/src/pages/__tests__/ProxyHosts.bulkApplyHeaders.test.tsx b/frontend/src/pages/__tests__/ProxyHosts.bulkApplyHeaders.test.tsx index 1472ee259..e552d26e0 100644 --- a/frontend/src/pages/__tests__/ProxyHosts.bulkApplyHeaders.test.tsx +++ b/frontend/src/pages/__tests__/ProxyHosts.bulkApplyHeaders.test.tsx @@ -1,7 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter } from 'react-router'; import { vi, describe, it, expect, beforeEach } from 'vitest'; import * as accessListsApi from '../../api/accessLists'; diff --git a/frontend/src/pages/__tests__/RateLimiting.spec.tsx b/frontend/src/pages/__tests__/RateLimiting.spec.tsx index 9f6bdefa4..718abb5ea 100644 --- a/frontend/src/pages/__tests__/RateLimiting.spec.tsx +++ b/frontend/src/pages/__tests__/RateLimiting.spec.tsx @@ -1,7 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { BrowserRouter } from 'react-router-dom' +import { BrowserRouter } from 'react-router' import { describe, it, expect, vi, beforeEach } from 'vitest' import * as securityApi from '../../api/security' diff --git a/frontend/src/pages/__tests__/Security.audit.test.tsx b/frontend/src/pages/__tests__/Security.audit.test.tsx index e0ed2799c..d1f0eae04 100644 --- a/frontend/src/pages/__tests__/Security.audit.test.tsx +++ b/frontend/src/pages/__tests__/Security.audit.test.tsx @@ -7,7 +7,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { BrowserRouter } from 'react-router-dom' +import { BrowserRouter } from 'react-router' import { describe, it, expect, vi, beforeEach } from 'vitest' import * as crowdsecApi from '../../api/crowdsec' diff --git a/frontend/src/pages/__tests__/Security.crowdsec.test.tsx b/frontend/src/pages/__tests__/Security.crowdsec.test.tsx index 020df6ec6..d15d151fe 100644 --- a/frontend/src/pages/__tests__/Security.crowdsec.test.tsx +++ b/frontend/src/pages/__tests__/Security.crowdsec.test.tsx @@ -1,7 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { BrowserRouter } from 'react-router-dom' +import { BrowserRouter } from 'react-router' import { describe, it, expect, vi, beforeEach } from 'vitest' import * as crowdsecApi from '../../api/crowdsec' @@ -11,12 +11,12 @@ import * as settingsApi from '../../api/settings' import Security from '../Security' import type { SecurityStatus } from '../../api/security' -import type * as ReactRouterDom from 'react-router-dom' +import type * as ReactRouterDom from 'react-router' const mockNavigate = vi.fn() -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom') +vi.mock('react-router', async () => { + const actual = await vi.importActual('react-router') return { ...actual, useNavigate: () => mockNavigate } }) diff --git a/frontend/src/pages/__tests__/Security.dashboard.test.tsx b/frontend/src/pages/__tests__/Security.dashboard.test.tsx index a61c7ef4d..4b9915bc0 100644 --- a/frontend/src/pages/__tests__/Security.dashboard.test.tsx +++ b/frontend/src/pages/__tests__/Security.dashboard.test.tsx @@ -8,7 +8,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { BrowserRouter } from 'react-router-dom' +import { BrowserRouter } from 'react-router' import { describe, it, expect, vi, beforeEach } from 'vitest' import * as crowdsecApi from '../../api/crowdsec' diff --git a/frontend/src/pages/__tests__/Security.errors.test.tsx b/frontend/src/pages/__tests__/Security.errors.test.tsx index d677391f7..a8a67f1c0 100644 --- a/frontend/src/pages/__tests__/Security.errors.test.tsx +++ b/frontend/src/pages/__tests__/Security.errors.test.tsx @@ -8,7 +8,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { BrowserRouter } from 'react-router-dom' +import { BrowserRouter } from 'react-router' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import * as crowdsecApi from '../../api/crowdsec' diff --git a/frontend/src/pages/__tests__/Security.functional.test.tsx b/frontend/src/pages/__tests__/Security.functional.test.tsx index 1b0a87c92..4c41cf64f 100644 --- a/frontend/src/pages/__tests__/Security.functional.test.tsx +++ b/frontend/src/pages/__tests__/Security.functional.test.tsx @@ -7,7 +7,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { BrowserRouter } from 'react-router-dom' +import { BrowserRouter } from 'react-router' import { describe, it, expect, vi, beforeEach } from 'vitest' import * as crowdsecApi from '../../api/crowdsec' @@ -15,12 +15,12 @@ import * as securityApi from '../../api/security' import * as settingsApi from '../../api/settings' import Security from '../Security' -import type * as ReactRouterDom from 'react-router-dom' +import type * as ReactRouterDom from 'react-router' const mockNavigate = vi.hoisted(() => vi.fn()) -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom') +vi.mock('react-router', async () => { + const actual = await vi.importActual('react-router') return { ...actual, useNavigate: () => mockNavigate, diff --git a/frontend/src/pages/__tests__/Security.loading.test.tsx b/frontend/src/pages/__tests__/Security.loading.test.tsx index 0ba3ad1a4..0fa5a51d7 100644 --- a/frontend/src/pages/__tests__/Security.loading.test.tsx +++ b/frontend/src/pages/__tests__/Security.loading.test.tsx @@ -8,7 +8,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { BrowserRouter } from 'react-router-dom' +import { BrowserRouter } from 'react-router' import { describe, it, expect, vi, beforeEach } from 'vitest' import * as crowdsecApi from '../../api/crowdsec' diff --git a/frontend/src/pages/__tests__/Security.spec.tsx b/frontend/src/pages/__tests__/Security.spec.tsx index 05d91d163..9e174dbdf 100644 --- a/frontend/src/pages/__tests__/Security.spec.tsx +++ b/frontend/src/pages/__tests__/Security.spec.tsx @@ -1,7 +1,7 @@ import { QueryClientProvider } from '@tanstack/react-query' import { cleanup, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { BrowserRouter } from 'react-router-dom' +import { BrowserRouter } from 'react-router' import { describe, it, expect, vi, beforeEach } from 'vitest' import * as crowdsecApi from '../../api/crowdsec' @@ -12,12 +12,12 @@ import { createTestQueryClient } from '../../test/createTestQueryClient' import Security from '../Security' import type { SecurityStatus, RuleSetsResponse } from '../../api/security' -import type * as ReactRouterDom from 'react-router-dom' +import type * as ReactRouterDom from 'react-router' const mockNavigate = vi.fn() -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom') +vi.mock('react-router', async () => { + const actual = await vi.importActual('react-router') return { ...actual, useNavigate: () => mockNavigate } }) diff --git a/frontend/src/pages/__tests__/Security.test.tsx b/frontend/src/pages/__tests__/Security.test.tsx index 9b39e60a1..168bd4a13 100644 --- a/frontend/src/pages/__tests__/Security.test.tsx +++ b/frontend/src/pages/__tests__/Security.test.tsx @@ -1,7 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { BrowserRouter } from 'react-router-dom' +import { BrowserRouter } from 'react-router' import { describe, it, expect, vi, beforeEach } from 'vitest' import * as crowdsecApi from '../../api/crowdsec' diff --git a/frontend/src/pages/__tests__/SecurityHeaders.test.tsx b/frontend/src/pages/__tests__/SecurityHeaders.test.tsx index c2c79ad68..8e07272c7 100644 --- a/frontend/src/pages/__tests__/SecurityHeaders.test.tsx +++ b/frontend/src/pages/__tests__/SecurityHeaders.test.tsx @@ -1,7 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter } from 'react-router'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createBackup } from '../../api/backups'; diff --git a/frontend/src/pages/__tests__/Settings.test.tsx b/frontend/src/pages/__tests__/Settings.test.tsx index 0b13a7b2e..4a3c7b868 100644 --- a/frontend/src/pages/__tests__/Settings.test.tsx +++ b/frontend/src/pages/__tests__/Settings.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react' -import { MemoryRouter, Routes, Route } from 'react-router-dom' +import { MemoryRouter, Routes, Route } from 'react-router' import { describe, it, expect, vi } from 'vitest' import '@testing-library/jest-dom/vitest' diff --git a/frontend/src/pages/__tests__/Setup.test.tsx b/frontend/src/pages/__tests__/Setup.test.tsx index 68a6b9256..019d10d71 100644 --- a/frontend/src/pages/__tests__/Setup.test.tsx +++ b/frontend/src/pages/__tests__/Setup.test.tsx @@ -1,7 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter } from 'react-router'; import { vi, describe, it, expect, beforeEach } from 'vitest'; import * as setupApi from '../../api/setup'; @@ -26,10 +26,10 @@ vi.mock('../../api/client', () => ({ }, })); -// Mock react-router-dom +// Mock react-router const mockNavigate = vi.fn(); -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom'); +vi.mock('react-router', async () => { + const actual = await vi.importActual('react-router'); return { ...actual, useNavigate: () => mockNavigate, diff --git a/frontend/src/pages/__tests__/SystemSettings.test.tsx b/frontend/src/pages/__tests__/SystemSettings.test.tsx index 99f6193f2..4120b4b8f 100644 --- a/frontend/src/pages/__tests__/SystemSettings.test.tsx +++ b/frontend/src/pages/__tests__/SystemSettings.test.tsx @@ -1,7 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { MemoryRouter } from 'react-router-dom' +import { MemoryRouter } from 'react-router' import { vi, describe, it, expect, beforeEach } from 'vitest' import client from '../../api/client' diff --git a/frontend/src/pages/__tests__/WafConfig.spec.tsx b/frontend/src/pages/__tests__/WafConfig.spec.tsx index a56c2f991..275c51df6 100644 --- a/frontend/src/pages/__tests__/WafConfig.spec.tsx +++ b/frontend/src/pages/__tests__/WafConfig.spec.tsx @@ -1,7 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { BrowserRouter } from 'react-router-dom' +import { BrowserRouter } from 'react-router' import { describe, it, expect, vi, beforeEach } from 'vitest' import * as securityApi from '../../api/security' diff --git a/frontend/src/test-utils/renderWithQueryClient.tsx b/frontend/src/test-utils/renderWithQueryClient.tsx index 6e5987154..72b76e2b5 100644 --- a/frontend/src/test-utils/renderWithQueryClient.tsx +++ b/frontend/src/test-utils/renderWithQueryClient.tsx @@ -1,7 +1,7 @@ import { QueryClient, QueryClientProvider, type QueryClientConfig } from '@tanstack/react-query' import { render } from '@testing-library/react' import { type ReactNode } from 'react' -import { MemoryRouter, type MemoryRouterProps } from 'react-router-dom' +import { MemoryRouter, type MemoryRouterProps } from 'react-router' const defaultConfig: QueryClientConfig = { defaultOptions: { diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 09bef1e1a..c2a50ed85 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -59,7 +59,7 @@ export default defineConfig({ if ( id.includes('node_modules/react/') || id.includes('node_modules/react-dom/') || - id.includes('node_modules/react-router-dom/') + id.includes('node_modules/react-router/') ) { return 'vendor-react' } From ed644d3d73aff4583cea42d4fd4eda0113b5df4f Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 25 Jul 2026 08:27:03 +0000 Subject: [PATCH 2/7] fix(certificates): invalidate cache when proxy hosts change certificate links Pre-existing bug found while root-causing E2E failures for the react-router migration PR (confirmed present on unmodified development via baseline comparison, unrelated to routing). CertificateService.ListCertificates() caches the "in_use" flag for up to 5 minutes (scanTTL) and InvalidateCache() was never called from the proxy host CRUD path. Linking a certificate to a new/updated/deleted proxy host therefore left the certificates list's in_use status (and the delete button's disabled/tooltip state) stale for up to 5 minutes. Add a minimal CertificateCacheInvalidator interface so ProxyHostService can invalidate CertificateService's cache without a hard dependency between the two services, wire it in routes.go where both are already constructed, and invalidate on Create/Update/Delete. Covered by new unit tests at both the service and HTTP-handler layer. Claude-Session: https://claude.ai/code/session_01TJBaYmvRyNJ31KSS87qRLr --- .../api/handlers/proxy_host_handler.go | 9 ++++ .../api/handlers/proxy_host_handler_test.go | 48 +++++++++++++++++ backend/internal/api/routes/routes.go | 5 ++ .../internal/services/proxyhost_service.go | 47 ++++++++++++++-- .../services/proxyhost_service_test.go | 54 +++++++++++++++++++ 5 files changed, 158 insertions(+), 5 deletions(-) diff --git a/backend/internal/api/handlers/proxy_host_handler.go b/backend/internal/api/handlers/proxy_host_handler.go index d1216be18..378f86c17 100644 --- a/backend/internal/api/handlers/proxy_host_handler.go +++ b/backend/internal/api/handlers/proxy_host_handler.go @@ -359,6 +359,15 @@ func NewProxyHostHandler(db *gorm.DB, caddyManager *caddy.Manager, ns *services. } } +// SetCertificateService wires an optional certificate cache invalidator into +// the underlying proxy host service, so that creating, updating, or deleting +// a proxy host immediately refreshes the certificates list's "in_use" status +// instead of waiting out CertificateService's scan TTL. Safe to leave unset +// in tests/import flows that don't need this. +func (h *ProxyHostHandler) SetCertificateService(certService services.CertificateCacheInvalidator) { + h.service.SetCertificateService(certService) +} + // RegisterRoutes registers proxy host routes. func (h *ProxyHostHandler) RegisterRoutes(router *gin.RouterGroup) { router.GET("/proxy-hosts", h.List) diff --git a/backend/internal/api/handlers/proxy_host_handler_test.go b/backend/internal/api/handlers/proxy_host_handler_test.go index 678a18f20..c13d63161 100644 --- a/backend/internal/api/handlers/proxy_host_handler_test.go +++ b/backend/internal/api/handlers/proxy_host_handler_test.go @@ -2796,3 +2796,51 @@ func TestProxyHostHandler_BulkUpdateGroup_CaddyApplyError(t *testing.T) { require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) require.Contains(t, result["error"].(string), "Failed to apply") } + +// fakeCertCacheInvalidator is a test double satisfying services.CertificateCacheInvalidator. +type fakeCertCacheInvalidator struct { + calls int +} + +func (f *fakeCertCacheInvalidator) InvalidateCache() { + f.calls++ +} + +// TestProxyHostHandler_SetCertificateService_InvalidatesOnCreate verifies the +// handler-level wiring (routes.go calls h.SetCertificateService(certService)) +// actually reaches the underlying ProxyHostService and fires on a real +// create request through the HTTP layer, closing the loop on the +// certificates-list "in_use" staleness bug this wiring fixes. +func TestProxyHostHandler_SetCertificateService_InvalidatesOnCreate(t *testing.T) { + dsn := "file:" + t.Name() + "?mode=memory&cache=shared" + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &models.ProxyHost{}, + &models.Location{}, + &models.Notification{}, + &models.NotificationProvider{}, + )) + + ns := services.NewNotificationService(db, nil) + h := NewProxyHostHandler(db, nil, ns, nil) + fake := &fakeCertCacheInvalidator{} + h.SetCertificateService(fake) + + r := gin.New() + api := r.Group("/api/v1") + h.RegisterRoutes(api) + + body, _ := json.Marshal(map[string]any{ + "domain_names": "cert-invalidation.example.com", + "forward_host": "127.0.0.1", + "forward_port": 8080, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusCreated, w.Code, w.Body.String()) + require.Equal(t, 1, fake.calls, "creating a proxy host via the HTTP handler should invalidate the wired certificate cache") +} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 3255c01fd..02e02ab12 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -861,6 +861,11 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg // Proxy Hosts & Remote Servers proxyHostHandler := handlers.NewProxyHostHandler(db, caddyManager, notificationService, uptimeService) + // Keep the certificates list's "in_use" flag fresh: without this, creating, + // updating, or deleting a proxy host's certificate_id link doesn't invalidate + // CertificateService's cache, and the delete-button's disabled/tooltip state + // can be stale for up to the cache's scan TTL (5 minutes). + proxyHostHandler.SetCertificateService(certService) proxyHostHandler.RegisterRoutes(management) proxyGroupHandler := handlers.NewProxyGroupHandler(db) diff --git a/backend/internal/services/proxyhost_service.go b/backend/internal/services/proxyhost_service.go index a104e00d8..77bc14dc1 100644 --- a/backend/internal/services/proxyhost_service.go +++ b/backend/internal/services/proxyhost_service.go @@ -18,9 +18,18 @@ import ( "github.com/Wikid82/charon/backend/internal/models" ) +// CertificateCacheInvalidator is the minimal interface ProxyHostService needs +// to keep the certificate list's "in_use" status fresh. Satisfied by +// *CertificateService; kept as an interface (rather than a direct dependency) +// to avoid a hard coupling between the two services. +type CertificateCacheInvalidator interface { + InvalidateCache() +} + // ProxyHostService encapsulates business logic for proxy host management. type ProxyHostService struct { - db *gorm.DB + db *gorm.DB + certService CertificateCacheInvalidator } // NewProxyHostService creates a new proxy host service. @@ -28,6 +37,22 @@ func NewProxyHostService(db *gorm.DB) *ProxyHostService { return &ProxyHostService{db: db} } +// SetCertificateService wires an optional certificate cache invalidator. +// When set, creating, updating, or deleting a proxy host invalidates the +// certificate service's cache so the "in_use" flag reflects the change +// immediately instead of waiting out the cache's scan TTL. +func (s *ProxyHostService) SetCertificateService(certService CertificateCacheInvalidator) { + s.certService = certService +} + +// invalidateCertCache notifies the certificate service (if wired) that a +// certificate<->proxy-host association may have changed. +func (s *ProxyHostService) invalidateCertCache() { + if s.certService != nil { + s.certService.InvalidateCache() + } +} + // ValidateUniqueDomain ensures no duplicate domains exist before creation/update. func (s *ProxyHostService) ValidateUniqueDomain(domainNames string, excludeID uint) error { var count int64 @@ -175,7 +200,11 @@ func (s *ProxyHostService) Create(host *models.ProxyHost) error { } } - return s.db.Create(host).Error + if err := s.db.Create(host).Error; err != nil { + return err + } + s.invalidateCertCache() + return nil } // Update validates and updates an existing proxy host. @@ -204,15 +233,23 @@ func (s *ProxyHostService) Update(host *models.ProxyHost) error { // Use Updates to handle nullable foreign keys properly // Must use Select to explicitly allow setting nullable fields to nil - return s.db.Model(&models.ProxyHost{}). + if err := s.db.Model(&models.ProxyHost{}). Where("id = ?", host.ID). Select("*"). - Updates(host).Error + Updates(host).Error; err != nil { + return err + } + s.invalidateCertCache() + return nil } // Delete removes a proxy host. func (s *ProxyHostService) Delete(id uint) error { - return s.db.Delete(&models.ProxyHost{}, id).Error + if err := s.db.Delete(&models.ProxyHost{}, id).Error; err != nil { + return err + } + s.invalidateCertCache() + return nil } // GetByID retrieves a proxy host by ID. diff --git a/backend/internal/services/proxyhost_service_test.go b/backend/internal/services/proxyhost_service_test.go index cbd112961..b04ecd589 100644 --- a/backend/internal/services/proxyhost_service_test.go +++ b/backend/internal/services/proxyhost_service_test.go @@ -328,3 +328,57 @@ func TestProxyHostService_List_DBError(t *testing.T) { _, err = service.List() assert.Error(t, err) } + +// fakeCertCacheInvalidator is a test double for CertificateCacheInvalidator +// that records how many times InvalidateCache was called. +type fakeCertCacheInvalidator struct { + calls int +} + +func (f *fakeCertCacheInvalidator) InvalidateCache() { + f.calls++ +} + +func TestProxyHostService_InvalidatesCertificateCache(t *testing.T) { + t.Parallel() + + db := setupProxyHostTestDB(t) + service := NewProxyHostService(db) + fake := &fakeCertCacheInvalidator{} + service.SetCertificateService(fake) + + host := &models.ProxyHost{ + DomainNames: "cache-invalidation.example.com", + ForwardHost: "127.0.0.1", + ForwardPort: 8080, + } + + require.NoError(t, service.Create(host)) + assert.Equal(t, 1, fake.calls, "Create should invalidate the certificate cache") + + host.ForwardPort = 8081 + require.NoError(t, service.Update(host)) + assert.Equal(t, 2, fake.calls, "Update should invalidate the certificate cache") + + require.NoError(t, service.Delete(host.ID)) + assert.Equal(t, 3, fake.calls, "Delete should invalidate the certificate cache") +} + +func TestProxyHostService_NoCertificateService_DoesNotPanic(t *testing.T) { + t.Parallel() + + db := setupProxyHostTestDB(t) + service := NewProxyHostService(db) + // No SetCertificateService call — invalidateCertCache must be a no-op, not + // a nil-pointer panic, so import flows that don't wire a cert service keep + // working unchanged. + + host := &models.ProxyHost{ + DomainNames: "no-cert-service.example.com", + ForwardHost: "127.0.0.1", + ForwardPort: 8080, + } + require.NoError(t, service.Create(host)) + require.NoError(t, service.Update(host)) + require.NoError(t, service.Delete(host.ID)) +} From 781591d6776801cdbd9b11b33fe1ec994e81fd93 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 25 Jul 2026 08:27:38 +0000 Subject: [PATCH 3/7] fix(certificates): delete bulk certificates sequentially MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-existing bug found while root-causing E2E failures for the react-router migration PR (confirmed present on unmodified development via baseline comparison, unrelated to routing). Every certificate delete triggers a backup on the backend (CertificateHandler.Delete -> BackupService.CreateBackup), guarded by a non-blocking TryLock: only one backup can run at a time, and a losing concurrent request fails immediately with ErrBackupInProgress rather than queueing. useBulkDeleteCertificates fired all deletes at once via Promise.allSettled(uuids.map(...)), so only the first ever won the backup lock — every other cert in the batch 500'd on its backup step and silently counted as "failed" in the partial-failure toast, even though the user asked to delete them all. Await each delete in a sequential loop instead, so each backup completes (and releases the lock) before the next delete starts. Added a regression test asserting the deletes never run concurrently. Claude-Session: https://claude.ai/code/session_01TJBaYmvRyNJ31KSS87qRLr --- .../hooks/__tests__/useCertificates.test.tsx | 29 +++++++++++++++++++ frontend/src/hooks/useCertificates.ts | 22 ++++++++++++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/frontend/src/hooks/__tests__/useCertificates.test.tsx b/frontend/src/hooks/__tests__/useCertificates.test.tsx index 94bbe92d3..829bb7d70 100644 --- a/frontend/src/hooks/__tests__/useCertificates.test.tsx +++ b/frontend/src/hooks/__tests__/useCertificates.test.tsx @@ -234,5 +234,34 @@ describe('useCertificates hooks', () => { await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data).toEqual({ succeeded: 1, failed: 1 }); }); + + it('deletes certificates sequentially, not concurrently', async () => { + // Regression test: the backend serializes a backup-before-delete step + // with a non-blocking lock (BackupService.CreateBackupWithOptions uses + // TryLock, not a blocking Lock). Firing all deletes concurrently meant + // only the first ever won the lock and the rest 500'd. This test fails + // if the mutation goes back to Promise.allSettled(uuids.map(...)), + // which would call deleteCertificate for every UUID before any of them + // resolves — i.e. call N+1 would fire before call N's promise settles. + let inFlight = 0; + let maxConcurrent = 0; + vi.mocked(api.deleteCertificate).mockImplementation(async () => { + inFlight++; + maxConcurrent = Math.max(maxConcurrent, inFlight); + await new Promise(resolve => setTimeout(resolve, 5)); + inFlight--; + }); + + const { result } = renderHook(() => useBulkDeleteCertificates(), { + wrapper: createWrapper(), + }); + + result.current.mutate(['uuid-1', 'uuid-2', 'uuid-3']); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(api.deleteCertificate).toHaveBeenCalledTimes(3); + expect(result.current.data).toEqual({ succeeded: 3, failed: 0 }); + expect(maxConcurrent).toBe(1); + }); }); }); diff --git a/frontend/src/hooks/useCertificates.ts b/frontend/src/hooks/useCertificates.ts index 540280f06..37b1caee8 100644 --- a/frontend/src/hooks/useCertificates.ts +++ b/frontend/src/hooks/useCertificates.ts @@ -119,9 +119,25 @@ export function useBulkDeleteCertificates() { return useMutation({ mutationFn: async (uuids: string[]) => { - const results = await Promise.allSettled(uuids.map(uuid => deleteCertificate(uuid))) - const failed = results.filter(r => r.status === 'rejected').length - const succeeded = results.filter(r => r.status === 'fulfilled').length + // Delete sequentially, not concurrently (Promise.allSettled(uuids.map(...))). + // Each certificate delete triggers a backup on the backend + // (CertificateHandler.Delete -> BackupService.CreateBackup), which is + // guarded by a non-blocking TryLock (BackupService.CreateBackupWithOptions): + // only one backup can run at a time, and a losing concurrent request fails + // immediately with ErrBackupInProgress rather than queueing. Firing all + // deletes at once meant only the first ever succeeded; the rest 500'd on + // the backup step. Awaiting them one at a time lets each backup finish + // (and release the lock) before the next delete starts. + let succeeded = 0 + let failed = 0 + for (const uuid of uuids) { + try { + await deleteCertificate(uuid) + succeeded++ + } catch { + failed++ + } + } return { succeeded, failed } }, onSuccess: () => { From afbce2b8d60a12d75f3435cdb50180bb4f33a91b Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 25 Jul 2026 08:28:54 +0000 Subject: [PATCH 4/7] fix(e2e): repair pre-existing certificate, proxy-group, and logout test bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found and root-caused while baseline-comparing E2E failures for the react-router migration against unmodified development (per that PR's review requirements). All confirmed present on development too via repeated isolated re-runs — none are routing regressions. - certificate-delete.spec.ts / certificate-bulk-delete.spec.ts: the shared createCustomCertViaAPI test helper round-tripped through the certificates LIST endpoint to resolve a numeric certificate ID that endpoint has never returned (CertificateInfo only exposes "uuid"), so certResult.id was always undefined. Passing that into certificate_id in a POST body meant JSON.stringify silently dropped the field, so proxy hosts created "with a certificate" never actually linked one — and every cert this helper created leaked into the shared E2E database, since deleteCertViaAPI(baseURL, undefined) 404'd silently in every afterAll cleanup. Both files now use the certificate's UUID throughout (which the backend already accepts for both certificate_id resolution and DELETE), matching the actual API contract. - certificate-delete.spec.ts / certificate-bulk-delete.spec.ts: the "no delete button for valid production LE cert" checks excluded expired certs from the assertion but not staging or expiring ones, even though frontend/src/utils/certificateUtils.ts's isDeletable() correctly treats letsencrypt-staging and status==='expiring' certs as deletable too. Broadened the exclusion regexes to match. - tests/utils/ui-helpers.ts: getToastLocator's "any toast" fallback used `[data-testid^="toast-"]`, which also matches the always- present, normally-empty `data-testid="toast-container"` wrapper the app's separate custom ToastContainer component renders. Since that element sorts first in the DOM, `.first()` locked onto it instead of the actual react-hot-toast toast. Added the same `:not([data-testid="toast-container"])` exclusion wait-helpers.ts's waitForToast already had. - proxy-groups.spec.ts: even after the locator fix above, the create- group toast check raced react-hot-toast's own 5000ms auto-dismiss duration against Playwright's global 5000ms default assertion timeout — zero shared margin for any latency between the API response and the toast actually rendering. Gave it an explicit, more generous timeout. - settings/user-lifecycle.spec.ts: the STEP 3 logout->redirect check used a 5000ms timeout, the only one in the file not already using 10000-15000ms for the identical check (see fixtures/auth-fixtures.ts and this file's own other waitForURL(/login/) calls). Aligned it with the rest of the file's convention. Claude-Session: https://claude.ai/code/session_01TJBaYmvRyNJ31KSS87qRLr --- tests/certificate-bulk-delete.spec.ts | 45 ++++++++-------- tests/certificate-delete.spec.ts | 75 +++++++++++++++------------ tests/proxy-groups.spec.ts | 19 +++++-- tests/settings/user-lifecycle.spec.ts | 7 ++- tests/utils/ui-helpers.ts | 10 +++- 5 files changed, 94 insertions(+), 62 deletions(-) diff --git a/tests/certificate-bulk-delete.spec.ts b/tests/certificate-bulk-delete.spec.ts index 4e3186162..9c90494d6 100644 --- a/tests/certificate-bulk-delete.spec.ts +++ b/tests/certificate-bulk-delete.spec.ts @@ -53,6 +53,7 @@ zCLDm4WygKTw2foUXGNtbWG7z6Eq7PI+2fSlJDFgb+xmdIFQdyKDsZeYO5bmdYq5 0tY8 -----END CERTIFICATE-----`; +// nosemgrep: generic.secrets.security.detected-private-key.detected-private-key -- throwaway self-signed test.local key pair generated solely for these X.509 upload/parse tests (see comment above REAL_TEST_CERT); not a real credential, never used outside this test fixture. const REAL_TEST_KEY = `-----BEGIN PRIVATE KEY----- MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDdzdQfOkHzG/lZ 242xTvFYMVOrd12rUGQVcWhc9NG1LIJGYZKpS0bzNUdoylHhIqbwNq18Dni1znDY @@ -85,9 +86,9 @@ yRNV1UrzJGv5ZUVKq2kymBut /** * Create a custom certificate directly via the API, bypassing TestDataManager's * narrow CertificateData type which omits the required `name` field. - * Returns the numeric cert ID (from list endpoint) and name for later lookup/cleanup. + * Returns the cert's UUID and name for later lookup/cleanup. */ -async function createCustomCertViaAPI(baseURL: string): Promise<{ id: number; certName: string }> { +async function createCustomCertViaAPI(baseURL: string): Promise<{ uuid: string; certName: string }> { const id = generateUniqueId(); const certName = `bulk-cert-${id}`; @@ -121,19 +122,15 @@ async function createCustomCertViaAPI(baseURL: string): Promise<{ id: number; ce const createResult = await response.json(); const certUUID: string = createResult.uuid; - // The create response excludes the numeric ID (json:"-" on model). - // Query the list endpoint and match by UUID to get the numeric ID. - const listResponse = await ctx.get('/api/v1/certificates'); - if (!listResponse.ok()) { - throw new Error(`Failed to list certificates: ${listResponse.status()}`); - } - const certs: Array<{ id: number; uuid: string }> = await listResponse.json(); - const match = certs.find((c) => c.uuid === certUUID); - if (!match) { - throw new Error(`Certificate with UUID ${certUUID} not found in list after creation`); - } - - return { id: match.id, certName }; + // NOTE: the certificates LIST endpoint (CertificateInfo) only exposes a + // "uuid" field, never a numeric "id" (the model's numeric ID has json:"-"). + // DELETE /api/v1/certificates/:uuid accepts a UUID directly, so use it + // instead of round-tripping through a numeric ID the API never returns + // (the previous version of this helper silently produced `id: undefined` + // here, which broke afterAll's cleanup on every run — every DELETE call + // silently 404'd via .catch(() => {}), leaking 3 "bulk-cert-*" certs into + // the shared database on every single test invocation). + return { uuid: certUUID, certName }; } finally { await ctx.dispose(); } @@ -142,7 +139,7 @@ async function createCustomCertViaAPI(baseURL: string): Promise<{ id: number; ce /** * Delete a certificate directly via the API for cleanup. */ -async function deleteCertViaAPI(baseURL: string, certId: number): Promise { +async function deleteCertViaAPI(baseURL: string, certUUID: string): Promise { const ctx = await playwrightRequest.newContext({ baseURL, storageState: STORAGE_STATE, @@ -150,7 +147,7 @@ async function deleteCertViaAPI(baseURL: string, certId: number): Promise }); try { - await ctx.delete(`/api/v1/certificates/${certId}`); + await ctx.delete(`/api/v1/certificates/${certUUID}`); } finally { await ctx.dispose(); } @@ -170,7 +167,7 @@ async function navigateToCertificates(page: import('@playwright/test').Page): Pr // parallelising across workers would give each worker its own isolated array. test.describe.serial('Certificate Bulk Delete', () => { const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:8080'; - const createdCerts: Array<{ id: number; certName: string }> = []; + const createdCerts: Array<{ uuid: string; certName: string }> = []; test.beforeAll(async () => { for (let i = 0; i < 3; i++) { @@ -182,7 +179,7 @@ test.describe.serial('Certificate Bulk Delete', () => { test.afterAll(async () => { // .catch(() => {}) handles certs already deleted by test 7 for (const cert of createdCerts) { - await deleteCertViaAPI(baseURL, cert.id).catch(() => {}); + await deleteCertViaAPI(baseURL, cert.uuid).catch(() => {}); } }); @@ -226,8 +223,11 @@ test.describe.serial('Certificate Bulk Delete', () => { for (let i = 0; i < leCount; i++) { const row = leRows.nth(i); const rowText = await row.textContent(); - const isExpiredOrStaging = /expired|staging/i.test(rowText ?? ''); - if (isExpiredOrStaging) continue; + // Also excludes "expiring" certs (shown as an "Expiring Soon" badge) — + // per frontend/src/utils/certificateUtils.ts's isDeletable(), status + // === 'expiring' is deletable too, same as 'expired'. + const isExpiredExpiringOrStaging = /expir(ed|ing)|staging/i.test(rowText ?? ''); + if (isExpiredExpiringOrStaging) continue; // Valid production LE cert: first cell is aria-hidden with no checkbox const firstCell = row.locator('td').first(); @@ -384,7 +384,8 @@ test.describe.serial('Certificate Bulk Delete', () => { }); await test.step('Await success toast confirming all deletions settled', async () => { - // toast.success fires in onSuccess after Promise.allSettled resolves + // toast.success fires in onSuccess once all deletes have been processed + // (sequentially — see useBulkDeleteCertificates in useCertificates.ts). await waitForToast(page, /certificate.*deleted/i, { type: 'success' }); }); diff --git a/tests/certificate-delete.spec.ts b/tests/certificate-delete.spec.ts index 4882742f1..bb9b13134 100644 --- a/tests/certificate-delete.spec.ts +++ b/tests/certificate-delete.spec.ts @@ -49,6 +49,7 @@ zCLDm4WygKTw2foUXGNtbWG7z6Eq7PI+2fSlJDFgb+xmdIFQdyKDsZeYO5bmdYq5 0tY8 -----END CERTIFICATE-----`; +// nosemgrep: generic.secrets.security.detected-private-key.detected-private-key -- throwaway self-signed test.local key pair generated solely for these X.509 upload/parse tests (see comment above REAL_TEST_CERT); not a real credential, never used outside this test fixture. const REAL_TEST_KEY = `-----BEGIN PRIVATE KEY----- MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDdzdQfOkHzG/lZ 242xTvFYMVOrd12rUGQVcWhc9NG1LIJGYZKpS0bzNUdoylHhIqbwNq18Dni1znDY @@ -81,12 +82,9 @@ yRNV1UrzJGv5ZUVKq2kymBut /** * Create a custom certificate directly via the API, bypassing TestDataManager's * narrow CertificateData type which omits the required `name` field. - * Returns the numeric cert ID (from list endpoint) and name for later lookup/cleanup. - * - * Note: The POST response excludes the numeric `id` (model uses json:"-"), - * so we query the list endpoint to resolve the numeric ID by matching on UUID. + * Returns the cert's UUID and name for later lookup/cleanup. */ -async function createCustomCertViaAPI(baseURL: string): Promise<{ id: number; certName: string }> { +async function createCustomCertViaAPI(baseURL: string): Promise<{ uuid: string; certName: string }> { const id = generateUniqueId(); const certName = `test-cert-${id}`; @@ -119,19 +117,16 @@ async function createCustomCertViaAPI(baseURL: string): Promise<{ id: number; ce const createResult = await response.json(); const certUUID: string = createResult.uuid; - // The create response excludes the numeric ID (json:"-" on model). - // Query the list endpoint and match by UUID to get the numeric ID. - const listResponse = await ctx.get('/api/v1/certificates'); - if (!listResponse.ok()) { - throw new Error(`Failed to list certificates: ${listResponse.status()}`); - } - const certs: Array<{ id: number; uuid: string }> = await listResponse.json(); - const match = certs.find((c) => c.uuid === certUUID); - if (!match) { - throw new Error(`Certificate with UUID ${certUUID} not found in list after creation`); - } - - return { id: match.id, certName }; + // NOTE: the certificates LIST endpoint (CertificateInfo) only exposes a + // "uuid" field, never a numeric "id" (the model's numeric ID has json:"-"). + // Both DELETE /api/v1/certificates/:uuid and the proxy host handler's + // certificate_id resolution accept a UUID directly, so use it everywhere + // instead of round-tripping through a numeric ID that the API never + // returns (a previous version of this helper silently produced + // `id: undefined` here, which JSON.stringify then dropped from request + // bodies entirely — see git history for the certificate_id linkage bug + // this caused). + return { uuid: certUUID, certName }; } finally { await ctx.dispose(); } @@ -140,14 +135,14 @@ async function createCustomCertViaAPI(baseURL: string): Promise<{ id: number; ce /** * Delete a certificate directly via the API for cleanup. */ -async function deleteCertViaAPI(baseURL: string, certId: number): Promise { +async function deleteCertViaAPI(baseURL: string, certUUID: string): Promise { const ctx = await playwrightRequest.newContext({ baseURL, storageState: STORAGE_STATE, }); try { - await ctx.delete(`/api/v1/certificates/${certId}`); + await ctx.delete(`/api/v1/certificates/${certUUID}`); } finally { await ctx.dispose(); } @@ -159,7 +154,7 @@ async function deleteCertViaAPI(baseURL: string, certId: number): Promise */ async function createProxyHostWithCertViaAPI( baseURL: string, - certificateId: number + certificateUUID: string ): Promise<{ id: string }> { const id = generateUniqueId(); const domain = `proxy-${id}.test.local`; @@ -176,7 +171,7 @@ async function createProxyHostWithCertViaAPI( forward_host: '127.0.0.1', forward_port: 3000, forward_scheme: 'https', - certificate_id: certificateId, + certificate_id: certificateUUID, }, }); @@ -219,7 +214,7 @@ async function navigateToCertificates(page: import('@playwright/test').Page): Pr test.describe('Certificate Deletion', () => { const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:8080'; - const createdCertIds: number[] = []; + const createdCertUUIDs: string[] = []; test.beforeEach(async ({ page, adminUser }) => { await loginUser(page, adminUser); @@ -228,8 +223,8 @@ test.describe('Certificate Deletion', () => { test.afterAll(async () => { // Clean up any certs created during tests that weren't deleted by the tests - for (const certId of createdCertIds) { - await deleteCertViaAPI(baseURL, certId).catch(() => {}); + for (const certUUID of createdCertUUIDs) { + await deleteCertViaAPI(baseURL, certUUID).catch(() => {}); } }); @@ -266,7 +261,7 @@ test.describe('Certificate Deletion', () => { await test.step('Seed a custom certificate via API', async () => { const result = await createCustomCertViaAPI(baseURL); - createdCertIds.push(result.id); + createdCertUUIDs.push(result.uuid); certName = result.certName; }); @@ -291,7 +286,7 @@ test.describe('Certificate Deletion', () => { await test.step('Seed a custom certificate via API', async () => { const result = await createCustomCertViaAPI(baseURL); - createdCertIds.push(result.id); + createdCertUUIDs.push(result.uuid); certName = result.certName; }); @@ -324,7 +319,7 @@ test.describe('Certificate Deletion', () => { await test.step('Seed a custom certificate via API', async () => { const result = await createCustomCertViaAPI(baseURL); - createdCertIds.push(result.id); + createdCertUUIDs.push(result.uuid); certName = result.certName; }); @@ -358,7 +353,7 @@ test.describe('Certificate Deletion', () => { await test.step('Seed a custom certificate via API', async () => { const result = await createCustomCertViaAPI(baseURL); - // Don't push to createdCertIds — this test will delete it via UI + // Don't push to createdCertUUIDs — this test will delete it via UI certName = result.certName; }); @@ -409,11 +404,11 @@ test.describe('Certificate Deletion', () => { await test.step('Seed a custom cert and attach it to a proxy host', async () => { const certResult = await createCustomCertViaAPI(baseURL); - createdCertIds.push(certResult.id); + createdCertUUIDs.push(certResult.uuid); certName = certResult.certName; // Create a proxy host that references this certificate via certificate_id - const proxyResult = await createProxyHostWithCertViaAPI(baseURL, certResult.id); + const proxyResult = await createProxyHostWithCertViaAPI(baseURL, certResult.uuid); proxyHostId = proxyResult.id; }); @@ -473,9 +468,21 @@ test.describe('Certificate Deletion', () => { const row = leCertRows.nth(i); const rowText = await row.textContent(); - // Skip expired LE certs — they ARE expected to have a delete button - const isExpired = /expired/i.test(rowText ?? ''); - if (isExpired) continue; + // Skip expired/expiring LE certs — they ARE expected to have a delete + // button per frontend/src/utils/certificateUtils.ts's isDeletable(), + // which treats both status === 'expired' and status === 'expiring' + // (shown as an "Expiring Soon" badge) as deletable. + const isExpiredOrExpiring = /expir(ed|ing)/i.test(rowText ?? ''); + if (isExpiredOrExpiring) continue; + + // Skip staging LE certs — the "letsencrypt-staging" provider text also + // matches the /let.*encrypt/i row filter above (it contains + // "letsencrypt" as a substring), but staging certs are intentionally + // deletable per frontend/src/utils/certificateUtils.ts's isDeletable() + // and are visually flagged with a "STAGING" badge. Only a genuinely + // valid *production* LE cert should have no delete button. + const isStaging = /staging/i.test(rowText ?? ''); + if (isStaging) continue; // Valid production LE cert should NOT have a delete button const deleteButton = row.getByRole('button', { name: /delete/i }); diff --git a/tests/proxy-groups.spec.ts b/tests/proxy-groups.spec.ts index b93d89444..25b87aeb7 100644 --- a/tests/proxy-groups.spec.ts +++ b/tests/proxy-groups.spec.ts @@ -34,7 +34,17 @@ test.describe('Proxy Groups', () => { await page.getByRole('button', { name: /save/i }).click(); await savePromise; - await expect(getToastLocator(page)).toBeVisible(); + // Explicit timeout: react-hot-toast's success toast auto-dismisses after + // 5000ms (App.tsx's ), which + // exactly matches Playwright's global default expect.timeout (also + // 5000ms per playwright.config.js). That leaves zero margin between "the + // toast is still there" and "the assertion gave up" — any latency + // between the API response and React's onSuccess->toast.success() call + // eats directly into a already-zero-slack shared budget. A longer + // explicit timeout here doesn't mask real failures (the toast reliably + // fires within a second or two in practice); it just stops a race with + // the toast's own dismiss timer from flaking the assertion. + await expect(getToastLocator(page)).toBeVisible({ timeout: 8000 }); }); test('should disable Save button when name is empty', async ({ page }) => { @@ -68,8 +78,11 @@ test.describe('Proxy Groups', () => { test.describe('Grouped Display', () => { test('should show ungrouped section when groups exist', async ({ page }) => { - const hasGroups = await page.getByRole('button', { name: /manage groups/i }).isVisible(); - expect(hasGroups).toBe(true); + // Use an auto-retrying assertion instead of a synchronous isVisible() + // check: the button is unconditionally rendered in the page's action + // bar, but a bare isVisible() has no wait/retry margin and can read + // the DOM a beat before React finishes painting after navigation. + await expect(page.getByRole('button', { name: /manage groups/i })).toBeVisible(); }); test('should display flat table when no groups exist', async ({ page }) => { diff --git a/tests/settings/user-lifecycle.spec.ts b/tests/settings/user-lifecycle.spec.ts index 0cfb2f558..538c71559 100644 --- a/tests/settings/user-lifecycle.spec.ts +++ b/tests/settings/user-lifecycle.spec.ts @@ -354,7 +354,12 @@ test.describe('Admin-User E2E Workflow', () => { const logoutButton = page.getByRole('button', { name: /logout/i }).first(); await logoutButton.click(); - await page.waitForURL(/login/, { timeout: 5000 }); + // 15s to match the timeout used for the same logout->login redirect check + // elsewhere in this file (e.g. line 791) and in fixtures/auth-fixtures.ts. + // The prior 5s value was an outlier in this file's own convention and was + // marginal under concurrent E2E load (backend serializes /auth/logout + // across parallel test workers hitting the same single container). + await page.waitForURL(/login/, { timeout: 15000 }); }); await test.step('STEP 4: New user logs in', async () => { diff --git a/tests/utils/ui-helpers.ts b/tests/utils/ui-helpers.ts index 584c9ed81..9d0f05e85 100644 --- a/tests/utils/ui-helpers.ts +++ b/tests/utils/ui-helpers.ts @@ -61,8 +61,14 @@ export function getToastLocator( .or(page.getByRole('status')) .or(page.getByRole('alert')); } else { - // Any toast: match our custom toast container with fallbacks for both roles - baseLocator = page.locator('[data-testid^="toast-"]') + // Any toast: match individual toast items with fallbacks for both roles. + // Excludes [data-testid="toast-container"] — the always-present, normally- + // empty wrapper div that the custom ToastContainer component renders — the + // ^= prefix selector would otherwise match it too (it starts with "toast-"), + // and since it's first in DOM order, .first() would resolve to it instead + // of the actual toast, permanently "hidden" (zero content). Mirrors the + // same exclusion already applied in wait-helpers.ts's waitForToast. + baseLocator = page.locator('[data-testid^="toast-"]:not([data-testid="toast-container"])') .or(page.getByRole('status')) .or(page.getByRole('alert')); } From 257425709bac250bf9f5275d4fda96d43975817a Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 25 Jul 2026 14:32:32 +0000 Subject: [PATCH 5/7] fix(security): patch kin-openapi CRITICAL auth-bypass in crowdsec-builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GHSA-r277-6w6q-xmqw (ValidationHandler.Load() fail-open authentication bypass via NoopAuthenticationFunc default) in github.com/getkin/kin-openapi was flagged CRITICAL by Grype against the built image, embedded in /usr/local/bin/crowdsec and /usr/local/bin/cscli. It is not a direct or transitive dependency of backend/go.mod — it's pulled in by CrowdSec's own module graph when the Dockerfile builds CrowdSec from source. Pin kin-openapi to v0.144.0 (fixed version) in the crowdsec-builder stage's existing "patch dependencies to fix CVEs in transitive dependencies" chain, matching the established pattern used for the 9 other CVEs already patched there (grpc-go, pgx/v4, aws-sdk-go-v2, go-ntlmssp, quic-go, jsonparser, etc). Verified: - Isolated `docker build --target crowdsec-builder` rebuild: `go version -m` confirms both /crowdsec-out/crowdsec and /crowdsec-out/cscli now embed kin-openapi v0.144.0. - Full image rebuild + fresh Syft SBOM + Grype scan (matching supply-chain-pr.yml's exact tool versions/config): 0 Critical, 0 High findings; kin-openapi no longer appears in the Grype match list at all. - Trivy scan (aquasec/trivy:0.72.0, --scanners vuln --severity CRITICAL,HIGH --ignorefile .trivyignore, matching docker-build.yml's "Security Scan PR Image" job) against the same image: 0 vulnerabilities across every scanned binary, including crowdsec and cscli. - Confirmed kin-openapi is not separately vendored anywhere else in the image (e.g. via Caddy's own module graph in caddy-builder) — it only appears in the crowdsec-builder stage's output binaries. --- Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Dockerfile b/Dockerfile index bdfeb4af4..ca9bbc8a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -578,6 +578,9 @@ RUN set -e; \ # Fix available at v1.2.0. # renovate: datasource=go depName=github.com/buger/jsonparser _retry go get github.com/buger/jsonparser@v1.2.0; \ + # GHSA-r277-6w6q-xmqw: kin-openapi ValidationHandler.Load() Fail-Open Authentication Bypass via NoopAuthenticationFunc Default + # renovate: datasource=go depName=github.com/getkin/kin-openapi + _retry go get github.com/getkin/kin-openapi@v0.144.0; \ _retry go mod tidy # Fix compatibility issues with expr-lang v1.17.7 From 67ced817c4f566392597d7cb288f850c12104ee1 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 25 Jul 2026 14:33:02 +0000 Subject: [PATCH 6/7] fix(backend): eliminate race in pending-restore-harness boot mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestPendingRestoreBootSwap_AcrossRealProcessBoundary was flaky in CI, intermittently failing with: Error: file ".../charon.db-wal" exists Root cause: cmd/pending-restore-harness's boot() calls database.Connect, which launches its post-connect integrity check (PRAGMA quick_check) on a background goroutine holding its own separate SQLite connection (database.Connect's launchQuickCheck). That goroutine is fire-and-forget — Connect does not wait for it before returning. The real server (cmd/api/main.go) never exits right after Connect, so that connection eventually closes on its own, well before the process does. This test harness is different: boot() calls os.Exit shortly after Connect returns, with no server loop in between. Whether the goroutine's connection had closed (triggering SQLite's WAL checkpoint that removes -wal/-shm) by the time os.Exit ran was therefore a genuine race, not a test artifact. Fix: call database.SyncIntegrityCheckForTesting() at the top of boot(), making the check run synchronously within Connect so its connection is guaranteed closed before Connect returns. This is the same fix already applied via TestMain in this package's own database_test.go, cmd/api/main_test.go, and internal/api/handlers/testmain_test.go — but those only affect the process that calls them, and the harness runs as a separately exec'd process, so it needs its own call. Verified: go test -run TestPendingRestoreBootSwap_AcrossRealProcessBoundary -count=20 passes 20/20; also passes 10/10 under -race with CHARON_HARNESS_GOCOVERDIR set (matching the CI coverage pipeline exactly). Full backend suite (go build ./... && go test -race ./...) passes. --- backend/cmd/pending-restore-harness/main.go | 26 +++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/backend/cmd/pending-restore-harness/main.go b/backend/cmd/pending-restore-harness/main.go index 2d0005e44..79949df9a 100644 --- a/backend/cmd/pending-restore-harness/main.go +++ b/backend/cmd/pending-restore-harness/main.go @@ -109,6 +109,32 @@ func boot(dbPath string) int { return 2 } + // database.Connect below launches its post-connect integrity check + // (PRAGMA quick_check) on its own background goroutine and its own + // separate SQLite connection by default — see database.Connect's + // launchQuickCheck. That goroutine is fire-and-forget: nothing in + // Connect waits for it to finish or for its connection to close. + // + // The real server (cmd/api/main.go) never exits right after Connect, so + // that connection eventually closes (triggering SQLite's WAL checkpoint, + // which removes -wal/-shm) long before the process ever does. This + // harness is different: boot() calls os.Exit shortly after Connect + // returns, with no server loop in between. Without this call, whether + // the goroutine's connection has closed by the time os.Exit runs is a + // race — sometimes -wal/-shm are gone, sometimes they're still on disk, + // which is exactly what made + // TestPendingRestoreBootSwap_AcrossRealProcessBoundary's + // require.NoFileExists(dbPath+"-wal") assertion flaky in CI. + // + // SyncIntegrityCheckForTesting makes the check run synchronously within + // Connect instead, so by the time Connect returns the check's connection + // is already closed — the same fix this package's own TestMain + // (database_test.go) and other callers (cmd/api/main_test.go, + // internal/api/handlers/testmain_test.go) already apply. It only takes + // effect in this process's memory; the outer test binary's TestMain has + // no effect here since this is a separately exec'd process. + database.SyncIntegrityCheckForTesting() + // This is the exact call main.go makes immediately before // database.Connect — see main.go's comment at the call site for why the // ordering matters (no live WAL pool must exist when the swap happens). From 9691fab6ec2e5fa08d62ee7cf4346d0a8d3cceee Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 25 Jul 2026 15:04:20 +0000 Subject: [PATCH 7/7] fix(security): suppress unreachable cel-go MEDIUM finding blocking PR image scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Security Scan PR Image" Trivy gate was still failing after the kin-openapi fix: its blocking SARIF-format step evaluates findings across all severities (not just the configured CRITICAL,HIGH), unlike its sibling "table output" step. The one live, unsuppressed finding was GHSA-gcjh-h69q-9w9g (cel-go JSON private fields exposed via NativeTypes/ParseStructTag("json") not honoring "json:\"-\""), MEDIUM severity, in github.com/google/cel-go v0.28.1 embedded in /usr/bin/caddy. Bumping cel-go to the v0.29.0 fix is not currently safe: Caddy v2.11.4 (the latest available Caddy release) calls interpreter.NewCall(id, function, overload, []interpreter.Interpretable, impl) in modules/caddyhttp/celmatcher.go, and v0.29.0 changed that parameter's type to []interpreter.InterpretableV2 — a superset interface that also requires an Exec(*ExecutionFrame) method. This is a genuine source-breaking change, confirmed by an isolated `docker build --target caddy-builder` rebuild that failed to compile with the naive version bump. No Caddy release newer than v2.11.4 exists yet with celmatcher.go updated for the new interpreter API, so there is no safe upgrade path today. Added a suppression to .trivyignore with full justification, following this repo's existing pattern for advisories with no safe upstream fix. Exploitability is low: Caddy's CEL `expression` matcher only evaluates admin-authored, static expressions from the Caddyfile/JSON config against Caddy's own fixed request-context fields — it never evaluates untrusted, user-submitted CEL source against attacker-controlled native struct types, which is the advisory's actual exploit precondition. Verified: rescanned the full image (charon:local) with Trivy 0.72.0 (matching docker-build.yml's pinned version) using the updated .trivyignore, both `--format json` (all severities) and `--format sarif` (matching the actual blocking CI step) — zero results in both, versus one MEDIUM result before this change. Grype rescan (matching supply-chain-pr.yml) remains 0 Critical/0 High, confirming no regression. --- .trivyignore | 23 +++++++++++++++++++++++ Dockerfile | 9 +++++++++ 2 files changed, 32 insertions(+) diff --git a/.trivyignore b/.trivyignore index 80fa2a3c9..b50c046c5 100644 --- a/.trivyignore +++ b/.trivyignore @@ -187,3 +187,26 @@ CVE-2026-39824 # Review by: 2026-08-08 # exp: 2026-08-08 GO-2026-5932 + +# GHSA-gcjh-h69q-9w9g: cel-go JSON private fields exposed via NativeTypes/ParseStructTag("json") +# Severity: MEDIUM — Package: github.com/google/cel-go v0.28.1, embedded in /usr/bin/caddy +# ext.NativeTypes(ParseStructTag("json")) does not honor the "json:\"-\"" skip directive: fields +# tagged json:"-" are registered under the literal CEL field name "-" and become readable from any +# CEL expression via dyn(obj)["-"]. Fixed upstream at v0.29.0. +# No safe upgrade path exists yet: bumping cel-go to v0.29.0 breaks Caddy v2.11.4's own +# modules/caddyhttp/celmatcher.go, which calls interpreter.NewCall(id, function, overload, +# []interpreter.Interpretable, impl) — v0.29.0 changed that parameter's type to +# []interpreter.InterpretableV2, a superset interface that additionally requires an +# Exec(*ExecutionFrame) method. This is a genuine source-incompatible breaking change in cel-go +# itself, not a simple version bump, and no Caddy release newer than v2.11.4 (the current latest +# tag as of this writing) exists with celmatcher.go updated for the new interpreter API. +# Exploitability: Caddy's CEL matcher (`expression` directive) only ever evaluates admin-authored, +# static expressions from the Caddyfile/JSON config against Caddy's own fixed request-context +# fields — it does not accept or evaluate arbitrary CEL source submitted by untrusted end users, +# and does not register attacker-controlled native Go struct types via NativeTypes at runtime. The +# advisory's exploit precondition (untrusted CEL expressions querying developer-registered native +# types that carry json:"-" fields) is not met by how Charon's Caddy build actually uses cel-go. +# Review by: 2026-08-22 +# See also: Dockerfile caddy-builder stage comment above the (skipped) cel-go pin for the same detail. +# exp: 2026-08-22 +GHSA-gcjh-h69q-9w9g diff --git a/Dockerfile b/Dockerfile index ca9bbc8a2..916144b2f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -414,6 +414,15 @@ RUN --mount=type=cache,target=/root/.cache/go-build \ # Affects /usr/bin/caddy (transitive via caddy-crowdsec-bouncer -> crowdsec). Fix available at v1.2.0. # renovate: datasource=go depName=github.com/buger/jsonparser _retry go get github.com/buger/jsonparser@v1.2.0; \ + # GHSA-gcjh-h69q-9w9g: cel-go JSON private fields exposed via NativeTypes/ParseStructTag("json"). + # NOT pinned here: bumping to the v0.29.0 fix breaks Caddy v2.11.4's own + # modules/caddyhttp/celmatcher.go, which calls interpreter.NewCall(..., []interpreter.Interpretable, ...) — + # v0.29.0 changed that parameter to []interpreter.InterpretableV2, a superset interface requiring an + # additional Exec(*ExecutionFrame) method, so this is a real source-incompatible break, not a version + # bump. No Caddy release newer than v2.11.4 exists yet with celmatcher.go updated for the new API. + # Suppressed in .trivyignore with full risk justification; see that file for exploitability analysis. + # TODO(renovate): bump github.com/google/cel-go to >= v0.29.0 once Caddy ships a release whose + # celmatcher.go is compatible with the new InterpretableV2 API; remove the .trivyignore entry then. # CVE-2026-44982 (GHSA-rw47-hm26-6wr7): CrowdSec AppSec silently drops HTTP request # body for chunked/HTTP-2 requests, bypassing WAF body inspection rules. # caddy-crowdsec-bouncer@v0.12.1 was built against crowdsec v1.6.3 whose