feat(api-tokens): add PAT management UI, CSRF handling, and auth-loading fixes

Add an API tokens management route to create, list, and revoke PATs, showing
the plaintext token once on creation with scope and server-id selection.

Mirror the nz-csrf cookie into the X-CSRF-Token header on unsafe fetcher
methods (POST/PUT/PATCH/DELETE) for the server-side double-submit check, and
self-heal expired sessions via refresh-token without a recursive fetch loop.

Gate protected routes behind resolved auth state to avoid pre-auth SWR fetches,
and fix the login loading/race so stale probes cannot clobber the session.

Add i18n keys for the new screens across all locales.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
This commit is contained in:
naiba
2026-05-30 15:56:53 +00:00
co-authored by cloudcode
parent f47d4a8772
commit f07c557029
37 changed files with 2041 additions and 69 deletions
+30
View File
@@ -0,0 +1,30 @@
import { expect, test } from "vitest"
import { parseExpiresInDaysInput } from "../api/api-tokens"
test("parseExpiresInDaysInput treats blank as 'never expires' (undefined)", () => {
expect(parseExpiresInDaysInput("")).toEqual({ ok: true, value: undefined })
expect(parseExpiresInDaysInput(" ")).toEqual({ ok: true, value: undefined })
})
test("parseExpiresInDaysInput accepts whole-number days in range", () => {
expect(parseExpiresInDaysInput("30")).toEqual({ ok: true, value: 30 })
expect(parseExpiresInDaysInput(" 3650 ")).toEqual({ ok: true, value: 3650 })
})
test("parseExpiresInDaysInput maps 0 to undefined (never expires)", () => {
expect(parseExpiresInDaysInput("0")).toEqual({ ok: true, value: undefined })
})
// Backend model field is `ExpiresInDays int` (nezha/model/api_token.go); a
// fractional value would fail JSON binding server-side, so the UI must reject
// it locally rather than send 1.5 and surface a confusing backend error.
test("parseExpiresInDaysInput rejects fractional days", () => {
expect(parseExpiresInDaysInput("1.5").ok).toBe(false)
})
test("parseExpiresInDaysInput rejects out-of-range and non-numeric input", () => {
expect(parseExpiresInDaysInput("-1").ok).toBe(false)
expect(parseExpiresInDaysInput("3651").ok).toBe(false)
expect(parseExpiresInDaysInput("abc").ok).toBe(false)
})
+26
View File
@@ -0,0 +1,26 @@
import { expect, test } from "vitest"
import { parseServerIDsInput } from "../api/api-tokens"
test("parseServerIDsInput returns undefined for empty input", () => {
expect(parseServerIDsInput("")).toEqual({ ok: true, value: undefined })
expect(parseServerIDsInput(" ")).toEqual({ ok: true, value: undefined })
})
test("parseServerIDsInput parses valid comma-separated positive ints", () => {
expect(parseServerIDsInput("1,2,3")).toEqual({ ok: true, value: [1, 2, 3] })
expect(parseServerIDsInput(" 10 , 11 ")).toEqual({ ok: true, value: [10, 11] })
})
test("parseServerIDsInput rejects any non-numeric token rather than silently dropping it", () => {
// 历史 UI 把 "1,abc" 静默裁剪为 [1] 再上送,等价于把"输入完整接受"骗给用户。
// 这条契约要求:任一片段非法 → 整次解析失败,前端必须报错而不是吞掉。
const r = parseServerIDsInput("1,abc")
expect(r.ok).toBe(false)
})
test("parseServerIDsInput rejects non-positive ids", () => {
expect(parseServerIDsInput("0").ok).toBe(false)
expect(parseServerIDsInput("-3").ok).toBe(false)
expect(parseServerIDsInput("1.5").ok).toBe(false)
})
+137
View File
@@ -0,0 +1,137 @@
import { afterEach, beforeEach, expect, test, vi } from "vitest"
import { createApiToken, deleteApiToken, listApiTokens } from "../api/api-tokens"
const realFetch = global.fetch
function mockFetch(payload: unknown, ok = true, success = true) {
global.fetch = vi.fn(async () => {
return new Response(JSON.stringify({ success, error: success ? "" : "boom", data: payload }), {
status: ok ? 200 : 500,
headers: { "Content-Type": "application/json" },
})
}) as unknown as typeof fetch
}
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
global.fetch = realFetch
vi.restoreAllMocks()
})
test("listApiTokens GETs /api/v1/api-tokens and returns parsed array", async () => {
const calls: Array<{ url: string; method: string }> = []
global.fetch = vi.fn(async (input: any, init?: any) => {
calls.push({ url: String(input), method: String(init?.method ?? "GET") })
return new Response(
JSON.stringify({
success: true,
data: [
{
id: 1,
name: "claude",
scopes: ["nezha:server:read"],
created_at: "2025-01-01T00:00:00Z",
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
)
}) as unknown as typeof fetch
const got = await listApiTokens()
expect(calls).toHaveLength(1)
expect(calls[0].method).toBe("GET")
expect(calls[0].url).toContain("/api/v1/api-tokens")
expect(got).toHaveLength(1)
expect(got[0].name).toBe("claude")
expect(got[0].scopes).toContain("nezha:server:read")
})
test("createApiToken POSTs /api/v1/api-tokens and serializes scopes / server_ids / expires_in_days", async () => {
let captured: { url: string; method: string; body: any } | null = null
global.fetch = vi.fn(async (input: any, init?: any) => {
captured = {
url: String(input),
method: String(init?.method ?? ""),
body: init?.body ? JSON.parse(init.body as string) : null,
}
return new Response(
JSON.stringify({
success: true,
data: {
id: 2,
name: "x",
token: "nzp_FAKEABC",
scopes: ["nezha:server:read", "nezha:server:write"],
server_ids: [10, 11],
expires_at: "2026-01-01T00:00:00Z",
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
)
}) as unknown as typeof fetch
const res = await createApiToken({
name: "x",
scopes: ["nezha:server:read", "nezha:server:write"],
server_ids: [10, 11],
expires_in_days: 30,
})
expect(res.token).toBe("nzp_FAKEABC")
expect(captured).not.toBeNull()
expect(captured!.method).toBe("POST")
expect(captured!.url).toContain("/api/v1/api-tokens")
expect(captured!.body.name).toBe("x")
expect(captured!.body.scopes).toEqual(["nezha:server:read", "nezha:server:write"])
expect(captured!.body.server_ids).toEqual([10, 11])
expect(captured!.body.expires_in_days).toBe(30)
})
test("createApiToken surfaces server error via thrown Error", async () => {
mockFetch(null, true, false)
await expect(
createApiToken({ name: "x", scopes: ["nezha:server:read"] }),
).rejects.toThrow("boom")
})
test("listApiTokens normalizes null scopes to an empty array so the table cannot crash", async () => {
// Backend APIToken.Scopes() returns nil for ScopesCSV=="" which JSON-encodes
// as null; migrated/legacy/hand-edited rows hit this. Without normalization
// the list page does tok.scopes.map(...) on null and the whole page crashes.
global.fetch = vi.fn(async () => {
return new Response(
JSON.stringify({
success: true,
data: [
{ id: 1, name: "legacy", scopes: null, created_at: "2025-01-01T00:00:00Z" },
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
)
}) as unknown as typeof fetch
const got = await listApiTokens()
expect(Array.isArray(got[0].scopes)).toBe(true)
expect(got[0].scopes).toEqual([])
})
test("deleteApiToken DELETEs /api/v1/api-tokens/:id", async () => {
const calls: Array<{ url: string; method: string }> = []
global.fetch = vi.fn(async (input: any, init?: any) => {
calls.push({ url: String(input), method: String(init?.method ?? "GET") })
return new Response(JSON.stringify({ success: true, data: null }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
}) as unknown as typeof fetch
await deleteApiToken(42)
expect(calls).toHaveLength(1)
expect(calls[0].method).toBe("DELETE")
expect(calls[0].url).toContain("/api/v1/api-tokens/42")
})
+151
View File
@@ -0,0 +1,151 @@
import { act, render } from "@testing-library/react"
import { useEffect } from "react"
import { afterEach, beforeEach, expect, test, vi } from "vitest"
let profileStore: { id: number; role: number } | undefined
const setProfileSpy = vi.fn((p: any) => {
profileStore = p
})
vi.mock("./useMainStore", () => ({}))
vi.mock("@/hooks/useMainStore", () => ({
useMainStore: (selector: any) =>
selector({ profile: profileStore, setProfile: setProfileSpy }),
}))
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k: string) => k }),
}))
vi.mock("sonner", () => ({ toast: () => {} }))
const navigate = vi.fn()
vi.mock("react-router-dom", () => ({
useNavigate: () => navigate,
}))
// Initial getProfile() stays pending forever so loading can only be cleared
// by an explicit login/logout, which is exactly what these tests assert.
let initialProfilePromise: Promise<any>
let getProfileCall = 0
const loginRequest = vi.fn(async () => {})
vi.mock("@/api/user", () => ({
getProfile: vi.fn(() => {
getProfileCall++
if (getProfileCall === 1) return initialProfilePromise
return Promise.resolve({ id: 42, role: 0 })
}),
login: () => loginRequest(),
}))
beforeEach(() => {
profileStore = undefined
getProfileCall = 0
initialProfilePromise = new Promise<any>(() => {})
setProfileSpy.mockClear()
navigate.mockClear()
})
afterEach(() => {
document.body.innerHTML = ""
vi.clearAllMocks()
})
// AuthProvider starts with loading=true and only clears it in the initial
// mount probe's finally{}. A user can log in while that probe is still in
// flight (ProtectedRoute renders the login page during loading). If login()
// does not clear loading itself, ProtectedRoute keeps returning null for
// /dashboard and the freshly-authenticated user sees a blank screen until the
// unrelated probe settles.
test("login() clears loading even while the initial profile probe is still pending", async () => {
const { AuthProvider, useAuth } = await import("@/hooks/useAuth")
const captured: { auth?: ReturnType<typeof useAuth> } = {}
function Capture() {
const auth = useAuth()
useEffect(() => {
captured.auth = auth
})
captured.auth = auth
return null
}
await act(async () => {
render(
<AuthProvider>
<Capture />
</AuthProvider>,
)
})
// Initial probe still pending -> loading must be true.
expect(captured.auth!.loading).toBe(true)
await act(async () => {
await captured.auth!.login("u", "p")
})
// Login succeeded; loading must be false so ProtectedRoute renders.
expect(profileStore).toEqual({ id: 42, role: 0 })
expect(captured.auth!.loading).toBe(false)
})
test("loginOauth2() clears loading even while the initial profile probe is still pending", async () => {
const { AuthProvider, useAuth } = await import("@/hooks/useAuth")
const captured: { auth?: ReturnType<typeof useAuth> } = {}
function Capture() {
const auth = useAuth()
useEffect(() => {
captured.auth = auth
})
captured.auth = auth
return null
}
await act(async () => {
render(
<AuthProvider>
<Capture />
</AuthProvider>,
)
})
expect(captured.auth!.loading).toBe(true)
await act(async () => {
await captured.auth!.loginOauth2()
})
expect(captured.auth!.loading).toBe(false)
})
test("logout() clears loading even while the initial profile probe is still pending", async () => {
const { AuthProvider, useAuth } = await import("@/hooks/useAuth")
const captured: { auth?: ReturnType<typeof useAuth> } = {}
function Capture() {
const auth = useAuth()
useEffect(() => {
captured.auth = auth
})
captured.auth = auth
return null
}
await act(async () => {
render(
<AuthProvider>
<Capture />
</AuthProvider>,
)
})
expect(captured.auth!.loading).toBe(true)
await act(async () => {
captured.auth!.logout()
})
expect(captured.auth!.loading).toBe(false)
})
+96
View File
@@ -0,0 +1,96 @@
import { act, render } from "@testing-library/react"
import { useEffect } from "react"
import { afterEach, beforeEach, expect, test, vi } from "vitest"
let profileStore: { id: number; role: number } | undefined
const setProfileSpy = vi.fn((p: any) => {
profileStore = p
})
vi.mock("./useMainStore", () => ({}))
vi.mock("@/hooks/useMainStore", () => ({
useMainStore: (selector: any) =>
selector({ profile: profileStore, setProfile: setProfileSpy }),
}))
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k: string) => k }),
}))
vi.mock("sonner", () => ({ toast: () => {} }))
const navigate = vi.fn()
vi.mock("react-router-dom", () => ({
useNavigate: () => navigate,
}))
// Deferred initial getProfile() so we can resolve/reject it AFTER login().
let rejectInitial: (e: any) => void
const initialProfilePromise = new Promise((_res, rej) => {
rejectInitial = rej
})
let getProfileCall = 0
const loginRequest = vi.fn(async () => {})
vi.mock("@/api/user", () => ({
getProfile: vi.fn(() => {
getProfileCall++
if (getProfileCall === 1) return initialProfilePromise
return Promise.resolve({ id: 42, role: 0 })
}),
login: () => loginRequest(),
}))
beforeEach(() => {
profileStore = undefined
getProfileCall = 0
setProfileSpy.mockClear()
navigate.mockClear()
})
afterEach(() => {
document.body.innerHTML = ""
vi.clearAllMocks()
})
// The AuthProvider fires getProfile() on mount. While that probe is in flight a
// user can submit the login form (ProtectedRoute renders the login page during
// loading). If the in-flight probe later REJECTS (e.g. it 401'd because the
// user was not yet authenticated), its catch{} must NOT clobber the profile a
// successful login() already set — otherwise the freshly-authenticated user is
// bounced back to the login page.
test("late-rejecting initial profile probe does not clobber a successful login", async () => {
const { AuthProvider, useAuth } = await import("@/hooks/useAuth")
const captured: { auth?: ReturnType<typeof useAuth> } = {}
function Capture() {
const auth = useAuth()
useEffect(() => {
captured.auth = auth
})
return null
}
await act(async () => {
render(
<AuthProvider>
<Capture />
</AuthProvider>,
)
})
// User logs in while the initial probe is still pending.
await act(async () => {
await captured.auth!.login("u", "p")
})
expect(profileStore).toEqual({ id: 42, role: 0 })
// Now the stale initial probe rejects (it was a pre-auth 401).
await act(async () => {
rejectInitial(new Error("401"))
await initialProfilePromise.catch(() => {})
})
// The logged-in profile must survive.
expect(profileStore).toEqual({ id: 42, role: 0 })
expect(setProfileSpy).not.toHaveBeenLastCalledWith(undefined)
})
+97
View File
@@ -0,0 +1,97 @@
import { afterEach, beforeEach, expect, test, vi } from "vitest"
import { FetcherMethod, fetcher } from "../api/api"
const realFetch = global.fetch
function setCookie(value: string) {
Object.defineProperty(document, "cookie", { value, configurable: true })
}
beforeEach(() => {
setCookie("")
})
afterEach(() => {
global.fetch = realFetch
vi.restoreAllMocks()
})
function jsonOk() {
return new Response(JSON.stringify({ success: true, data: null }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
}
function headerOf(init: RequestInit | undefined, name: string): string | null {
const h = init?.headers
if (!h) return null
if (h instanceof Headers) return h.get(name)
const rec = h as Record<string, string>
const key = Object.keys(rec).find((k) => k.toLowerCase() === name.toLowerCase())
return key ? rec[key] : null
}
// Backend csrfMiddleware (nezha/cmd/dashboard/controller/csrf.go) enforces a
// double-submit cookie on every cookie-auth unsafe method: it rejects the
// request unless X-CSRF-Token header == nz-csrf cookie. The fetcher must mirror
// the cookie into the header for POST/PATCH/PUT/DELETE.
test("POST sends X-CSRF-Token mirrored from nz-csrf cookie", async () => {
setCookie("nz-csrf=abc123; other=1")
const seen: { init?: RequestInit }[] = []
global.fetch = vi.fn(async (_input: any, init?: RequestInit) => {
seen.push({ init })
return jsonOk()
}) as unknown as typeof fetch
await fetcher(FetcherMethod.POST, "/api/v1/api-tokens", { name: "x" })
expect(headerOf(seen[0].init, "X-CSRF-Token")).toBe("abc123")
})
test("DELETE sends X-CSRF-Token mirrored from nz-csrf cookie", async () => {
setCookie("nz-csrf=del-token")
const seen: { init?: RequestInit }[] = []
global.fetch = vi.fn(async (_input: any, init?: RequestInit) => {
seen.push({ init })
return jsonOk()
}) as unknown as typeof fetch
await fetcher(FetcherMethod.DELETE, "/api/v1/api-tokens/7")
expect(headerOf(seen[0].init, "X-CSRF-Token")).toBe("del-token")
})
test("auto refresh-token uses POST (backend route is POST)", async () => {
vi.resetModules()
const { FetcherMethod: M, fetcher: f } = await import("../api/api")
setCookie("nz-jwt=sess; nz-csrf=c")
const seen: { url: string; init?: RequestInit }[] = []
global.fetch = vi.fn(async (input: any, init?: RequestInit) => {
seen.push({ url: String(input), init })
return jsonOk()
}) as unknown as typeof fetch
await f(M.GET, "/api/v1/server")
const refresh = seen.find((s) => s.url.includes("/api/v1/refresh-token"))
expect(refresh, "auto refresh request should be issued").toBeTruthy()
expect(refresh!.init?.method).toBe("POST")
})
// Revoke (DELETE) commonly returns 204 / empty body. The fetcher must not
// blow up on response.json() of an empty body and must resolve successfully.
test("DELETE tolerates an empty 204 response body", async () => {
global.fetch = vi.fn(async () => new Response(null, { status: 204 })) as unknown as typeof fetch
await expect(fetcher(FetcherMethod.DELETE, "/api/v1/api-tokens/9")).resolves.toBeUndefined()
})
test("empty 200 body does not throw", async () => {
global.fetch = vi.fn(
async () => new Response("", { status: 200 }),
) as unknown as typeof fetch
await expect(fetcher(FetcherMethod.DELETE, "/api/v1/api-tokens/9")).resolves.toBeUndefined()
})
+35
View File
@@ -0,0 +1,35 @@
import { afterEach, beforeEach, expect, test, vi } from "vitest"
import { FetcherMethod, fetcher } from "../api/api"
const realFetch = global.fetch
beforeEach(() => {
// Avoid the auto refresh-token branch interfering with assertions.
Object.defineProperty(document, "cookie", { value: "", configurable: true })
})
afterEach(() => {
global.fetch = realFetch
vi.restoreAllMocks()
})
// Regression: fetcher used to collapse GET and DELETE into the same HTTP GET,
// so DELETE callers (e.g. revoke API token) never actually hit the backend
// DELETE route. The wire-level method must match the requested FetcherMethod.
test("fetcher uses HTTP DELETE for FetcherMethod.DELETE", async () => {
const seen: { url: string; init?: RequestInit }[] = []
global.fetch = vi.fn(async (input: any, init?: RequestInit) => {
seen.push({ url: String(input), init })
return new Response(JSON.stringify({ success: true, data: null }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
}) as unknown as typeof fetch
await fetcher(FetcherMethod.DELETE, "/api/v1/api-tokens/42")
expect(seen).toHaveLength(1)
expect(seen[0].init?.method).toBe("DELETE")
expect(seen[0].url).toContain("/api/v1/api-tokens/42")
})
+61
View File
@@ -0,0 +1,61 @@
import { afterEach, beforeEach, expect, test, vi } from "vitest"
const realFetch = global.fetch
function setCookie(value: string) {
Object.defineProperty(document, "cookie", { value, configurable: true })
}
function jsonOk() {
return new Response(JSON.stringify({ success: true, data: null }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
}
beforeEach(() => {
setCookie("")
vi.resetModules()
})
afterEach(() => {
global.fetch = realFetch
vi.restoreAllMocks()
})
// Auto-refresh is a POST behind the CSRF gate. When the session still lacks the
// nz-csrf cookie (e.g. just upgraded), firing the refresh without a header only
// burns the 1h throttle window and 403s. The refresh must instead wait until a
// CSRF token is available so it can actually succeed once the cookie is seeded.
test("auto refresh is deferred while nz-csrf cookie is missing", async () => {
const { FetcherMethod, fetcher } = await import("../api/api")
setCookie("nz-jwt=session") // jwt present, but no nz-csrf yet
const urls: string[] = []
global.fetch = vi.fn(async (input: any) => {
urls.push(String(input))
return jsonOk()
}) as unknown as typeof fetch
await fetcher(FetcherMethod.GET, "/api/v1/server")
expect(urls.some((u) => u.includes("/api/v1/refresh-token"))).toBe(false)
})
// Once the cookie exists, the next GET must be allowed to fire the refresh —
// proving the missing-cookie skip did not permanently consume the throttle.
test("auto refresh fires after nz-csrf cookie becomes available", async () => {
const { FetcherMethod, fetcher } = await import("../api/api")
const urls: string[] = []
global.fetch = vi.fn(async (input: any) => {
urls.push(String(input))
return jsonOk()
}) as unknown as typeof fetch
setCookie("nz-jwt=session") // first GET: no csrf, refresh skipped
await fetcher(FetcherMethod.GET, "/api/v1/server")
expect(urls.some((u) => u.includes("/api/v1/refresh-token"))).toBe(false)
setCookie("nz-jwt=session; nz-csrf=seeded") // backend seeded it
await fetcher(FetcherMethod.GET, "/api/v1/server")
expect(urls.some((u) => u.includes("/api/v1/refresh-token"))).toBe(true)
})
+108
View File
@@ -0,0 +1,108 @@
import { act, render } from "@testing-library/react"
import { MemoryRouter, Route, Routes } from "react-router-dom"
import { afterEach, beforeEach, expect, test, vi } from "vitest"
// ProtectedRoute must NOT mount its children while AuthProvider is still
// running the initial getProfile() probe — except for the /dashboard/login
// path itself. Mounting the protected subtree during the probe would fire
// authenticated SWR fetches like /api/v1/setting before auth is confirmed.
// Without this contract, a regression in protect.tsx silently re-introduces
// pre-auth traffic and a flash of protected UI before redirect.
let mockProfile: { id: number; role: number } | undefined
let mockLoading = false
vi.mock("@/hooks/useAuth", () => ({
useAuth: () => ({ profile: mockProfile, loading: mockLoading }),
}))
beforeEach(() => {
mockProfile = undefined
mockLoading = true
})
afterEach(() => {
document.body.innerHTML = ""
})
function renderAtPath(path: string, child: React.ReactNode) {
return import("@/routes/protect").then(({ default: ProtectedRoute }) => {
return act(async () => {
render(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route
path="/dashboard/login"
element={
<ProtectedRoute>{child}</ProtectedRoute>
}
/>
<Route
path="/dashboard/*"
element={
<ProtectedRoute>{child}</ProtectedRoute>
}
/>
</Routes>
</MemoryRouter>,
)
})
})
}
test("ProtectedRoute does not mount children for protected paths while auth is loading", async () => {
await renderAtPath(
"/dashboard",
<div data-testid="protected-child">protected</div>,
)
expect(document.querySelector("[data-testid='protected-child']")).toBeNull()
})
test("ProtectedRoute renders children on the login page even while auth is loading", async () => {
await renderAtPath(
"/dashboard/login",
<div data-testid="login-child">login</div>,
)
expect(document.querySelector("[data-testid='login-child']")).not.toBeNull()
})
test("ProtectedRoute redirects unauthenticated users without mounting protected children", async () => {
mockLoading = false
mockProfile = undefined
const { default: ProtectedRoute } = await import("@/routes/protect")
await act(async () => {
render(
<MemoryRouter initialEntries={["/dashboard"]}>
<Routes>
<Route
path="/dashboard/login"
element={
<ProtectedRoute>
<div data-testid="login-child">login</div>
</ProtectedRoute>
}
/>
<Route
path="/dashboard/*"
element={
<ProtectedRoute>
<div data-testid="protected-child">protected</div>
</ProtectedRoute>
}
/>
</Routes>
</MemoryRouter>,
)
})
expect(document.querySelector("[data-testid='protected-child']")).toBeNull()
expect(document.querySelector("[data-testid='login-child']")).not.toBeNull()
})
test("ProtectedRoute renders children once an authenticated profile resolves", async () => {
mockLoading = false
mockProfile = { id: 1, role: 0 }
await renderAtPath(
"/dashboard",
<div data-testid="protected-child">protected</div>,
)
expect(document.querySelector("[data-testid='protected-child']")).not.toBeNull()
})
+74
View File
@@ -0,0 +1,74 @@
import { act, render } from "@testing-library/react"
import { afterEach, beforeEach, expect, test, vi } from "vitest"
vi.mock("sonner", () => ({ toast: () => {} }))
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (k: string) => k,
i18n: { language: "en", changeLanguage: () => {} },
}),
initReactI18next: { type: "3rdParty", init: () => undefined },
Trans: ({ children }: { children?: React.ReactNode }) => children ?? null,
}))
let mockProfile: { id: number; role: number } | undefined
let mockLoading = false
vi.mock("@/hooks/useAuth", () => ({
useAuth: () => ({ profile: mockProfile, loading: mockLoading }),
}))
vi.mock("@/hooks/useSetting", () => ({
default: () => ({ data: undefined, mutate: () => {} }),
}))
vi.mock("@/hooks/useNotfication", () => ({
useNotification: () => ({ notifierGroup: [] }),
}))
vi.mock("@/api/settings", () => ({ updateSettings: vi.fn() }))
const navigateRenders: string[] = []
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual<any>("react-router-dom")
return {
...actual,
Navigate: ({ to }: { to: string }) => {
navigateRenders.push(to)
return <div data-testid="nav-stub">redirect:{to}</div>
},
}
})
beforeEach(() => {
navigateRenders.length = 0
mockProfile = undefined
mockLoading = true
})
afterEach(() => {
document.body.innerHTML = ""
})
// SettingsPage 在 profile 还没 fetch 完成时不能就把用户当作非管理员重定向。
// useAuth.loading=true 表示请求未回来;此时必须按"加载中"渲染,不能 Navigate
// 到 /dashboard/settings/api-tokens,否则管理员每次直达 /dashboard/settings
// 都会先闪一下到 api-tokens 页。
test("SettingsPage waits for auth load before redirecting non-admin", async () => {
const { default: SettingsPage } = await import("@/routes/settings")
await act(async () => {
render(<SettingsPage />)
})
expect(navigateRenders).toEqual([])
})
// 一旦 loading=false 且确认 profile 不是 admin,才允许跳转。
test("SettingsPage redirects to api-tokens once auth resolves and user is not admin", async () => {
mockLoading = false
mockProfile = { id: 1, role: 1 }
const { default: SettingsPage } = await import("@/routes/settings")
await act(async () => {
render(<SettingsPage />)
})
expect(navigateRenders).toContain("/dashboard/settings/api-tokens")
})
+107
View File
@@ -0,0 +1,107 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import { afterEach, beforeEach, expect, test, vi } from "vitest"
const toastCalls: string[] = []
vi.mock("sonner", () => ({
toast: (msg: string) => {
toastCalls.push(msg)
},
}))
const changeLanguageCalls: string[] = []
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (k: string) => k,
i18n: {
language: "en",
changeLanguage: (lng: string) => {
changeLanguageCalls.push(lng)
},
},
}),
initReactI18next: { type: "3rdParty", init: () => undefined },
Trans: ({ children }: { children?: React.ReactNode }) => children ?? null,
}))
vi.mock("@/hooks/useAuth", () => ({
useAuth: () => ({ profile: { id: 1, role: 0 }, loading: false }),
}))
const validConfig = {
config: {
site_name: "Nezha",
language: "zh-CN",
user_template: "user-dist",
cover: 1,
ip_change_notification_group_id: 0,
},
frontend_templates: [],
}
vi.mock("@/hooks/useSetting", () => ({
default: () => ({ data: validConfig, mutate: vi.fn() }),
}))
vi.mock("@/hooks/useNotfication", () => ({
useNotification: () => ({ notifierGroup: [] }),
}))
const updateSettings = vi.fn()
vi.mock("@/api/settings", () => ({ updateSettings: (...args: unknown[]) => updateSettings(...args) }))
beforeEach(() => {
toastCalls.length = 0
changeLanguageCalls.length = 0
updateSettings.mockReset()
})
afterEach(() => {
document.body.innerHTML = ""
})
test("SettingsPage does not toast Success when updateSettings rejects", async () => {
updateSettings.mockRejectedValue(new Error("boom"))
const { default: SettingsPage } = await import("@/routes/settings")
await act(async () => {
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>,
)
})
const submit = screen.getByRole("button", { name: /Confirm|Submit|Save/i })
await act(async () => {
fireEvent.click(submit)
})
await waitFor(() => expect(updateSettings).toHaveBeenCalled())
expect(toastCalls).toContain("Error")
expect(toastCalls).not.toContain("Success")
expect(changeLanguageCalls).toEqual([])
})
test("SettingsPage toasts Success when updateSettings resolves", async () => {
updateSettings.mockResolvedValue(undefined)
const { default: SettingsPage } = await import("@/routes/settings")
await act(async () => {
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>,
)
})
const submit = screen.getByRole("button", { name: /Confirm|Submit|Save/i })
await act(async () => {
fireEvent.click(submit)
})
await waitFor(() => expect(updateSettings).toHaveBeenCalled())
expect(toastCalls).toContain("Success")
expect(toastCalls).not.toContain("Error")
})