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 bdfeb4af4..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 @@ -578,6 +587,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 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). 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)) +} 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/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: () => { 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' } 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')); }