mirror of
https://github.com/Buriburizaem0n/admin-frontend-domain.git
synced 2026-09-19 17:50:13 +00:00
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:
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
Reference in New Issue
Block a user