mirror of
https://github.com/Buriburizaem0n/admin-frontend-domain.git
synced 2026-09-19 09:40: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,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()
|
||||
})
|
||||
Reference in New Issue
Block a user