feat(web): AuthContext + клиент под cookie-сессии, projectId из контекста

This commit is contained in:
2026-07-03 21:00:18 +07:00
parent 4533b0ca25
commit b5d9e8f7ab
7 changed files with 374 additions and 65 deletions
+95 -10
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { api } from "./client"
import { DEFAULT_PROJECT_ID } from "@/lib/config"
import { api, UnauthorizedError } from "./client"
const PROJECT_ID = "11111111-1111-1111-1111-111111111111"
beforeEach(() => { vi.restoreAllMocks() })
@@ -13,19 +14,72 @@ function mockFetch(body: unknown, ok = true, status = 200) {
}
describe("api client", () => {
it("lists accounts at project-scoped path", async () => {
it("sends credentials:include on every request", async () => {
const spy = mockFetch([])
await api.listAccounts(PROJECT_ID)
const [, opts] = spy.mock.calls[0]
expect((opts as RequestInit).credentials).toBe("include")
})
describe("api.auth", () => {
it("login POSTs to /api/v1/auth/login with credentials:include", async () => {
const spy = mockFetch({ user: { id: "u1", email: "a@b.com" }, project: { id: "p1", name: "Default" } })
await api.auth.login("a@b.com", "secret")
const [url, opts] = spy.mock.calls[0]
expect(url).toBe("/api/v1/auth/login")
expect((opts as RequestInit).method).toBe("POST")
expect((opts as RequestInit).credentials).toBe("include")
expect(String((opts as RequestInit).body)).toContain("secret")
})
it("register POSTs to /api/v1/auth/register", async () => {
const spy = mockFetch({ user: { id: "u1", email: "a@b.com" }, project: { id: "p1", name: "Default" } })
await api.auth.register("a@b.com", "secret")
const [url, opts] = spy.mock.calls[0]
expect(url).toBe("/api/v1/auth/register")
expect((opts as RequestInit).method).toBe("POST")
})
it("logout POSTs to /api/v1/auth/logout", async () => {
const spy = mockFetch(undefined, true, 204)
await api.auth.logout()
const [url, opts] = spy.mock.calls[0]
expect(url).toBe("/api/v1/auth/logout")
expect((opts as RequestInit).method).toBe("POST")
})
it("me GETs /api/v1/auth/me and returns AuthState", async () => {
const state = { user: { id: "u1", email: "a@b.com" }, project: { id: "p1", name: "Default" } }
const spy = mockFetch(state)
const result = await api.auth.me()
const [url] = spy.mock.calls[0]
expect(url).toBe("/api/v1/auth/me")
expect(result).toEqual(state)
})
})
it("resource methods hit project-scoped path with projectId first", async () => {
const spy = mockFetch([{ id: "a1", provider: "selectel", comment: "" }])
const accounts = await api.listAccounts()
const accounts = await api.listAccounts(PROJECT_ID)
expect(accounts).toHaveLength(1)
expect(spy).toHaveBeenCalledWith(
`/api/v1/projects/${DEFAULT_PROJECT_ID}/accounts`,
`/api/v1/projects/${PROJECT_ID}/accounts`,
expect.objectContaining({ method: "GET" }),
)
})
it("listDomains(projectId) hits /api/v1/projects/{projectId}/domains", async () => {
const spy = mockFetch([])
await api.listDomains(PROJECT_ID)
expect(spy).toHaveBeenCalledWith(
`/api/v1/projects/${PROJECT_ID}/domains`,
expect.objectContaining({ method: "GET" }),
)
})
it("sends secret on account creation but path has no secret leakage in response typing", async () => {
const spy = mockFetch({ id: "a2", provider: "selectel", comment: "prod" })
await api.createAccount({ provider: "selectel", secret: "TOKEN", comment: "prod" })
await api.createAccount(PROJECT_ID, { provider: "selectel", secret: "TOKEN", comment: "prod" })
const [, opts] = spy.mock.calls[0]
expect((opts as RequestInit).method).toBe("POST")
expect(String((opts as RequestInit).body)).toContain("TOKEN")
@@ -33,14 +87,45 @@ describe("api client", () => {
it("throws on non-ok response", async () => {
mockFetch({ error: "boom" }, false, 500)
await expect(api.listDomains()).rejects.toThrow()
await expect(api.listDomains(PROJECT_ID)).rejects.toThrow()
})
it("applies with prune flag", async () => {
it("throws UnauthorizedError on 401", async () => {
mockFetch({ error: "unauthorized" }, false, 401)
await expect(api.listDomains(PROJECT_ID)).rejects.toThrow(UnauthorizedError)
})
it("applies with prune flag using projectId, id, body order", async () => {
const spy = mockFetch({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 })
await api.applyDomain("d1", { applyUpdates: true, applyPrunes: true })
await api.applyDomain(PROJECT_ID, "d1", { applyUpdates: true, applyPrunes: true })
const [url, opts] = spy.mock.calls[0]
expect(url).toContain("/domains/d1/apply")
expect(url).toBe(`/api/v1/projects/${PROJECT_ID}/domains/d1/apply`)
expect(String((opts as RequestInit).body)).toContain("applyPrunes")
})
it("checkDomain(projectId, id) hits project-scoped check path", async () => {
const spy = mockFetch({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 })
await api.checkDomain(PROJECT_ID, "d1")
expect(spy).toHaveBeenCalledWith(
`/api/v1/projects/${PROJECT_ID}/domains/d1/check`,
expect.objectContaining({ method: "GET" }),
)
})
it("importZones(projectId, accountId) hits project-scoped import path", async () => {
const spy = mockFetch([])
await api.importZones(PROJECT_ID, "acc1")
expect(spy).toHaveBeenCalledWith(
`/api/v1/projects/${PROJECT_ID}/accounts/acc1/import`,
expect.objectContaining({ method: "POST" }),
)
})
it("setDomainTemplate(projectId, id, templateId) hits project-scoped domain path", async () => {
const spy = mockFetch({ id: "d1", providerAccountId: "acc1", zoneName: "x.", zoneId: "z1" })
await api.setDomainTemplate(PROJECT_ID, "d1", "t1")
const [url, opts] = spy.mock.calls[0]
expect(url).toBe(`/api/v1/projects/${PROJECT_ID}/domains/d1`)
expect((opts as RequestInit).method).toBe("PATCH")
})
})
+60 -27
View File
@@ -1,15 +1,25 @@
import { API_BASE } from "@/lib/config"
import { API_ROOT } from "@/lib/config"
import type {
AuthState,
Account, CreateAccountInput, Template, CreateTemplateInput,
Domain, CreateDomainInput, ChangesetResponse, ApplyRequest,
} from "./types"
export class UnauthorizedError extends Error {
constructor() {
super("Unauthorized")
this.name = "UnauthorizedError"
}
}
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
const res = await fetch(path, {
headers: { "Content-Type": "application/json" },
method: "GET",
credentials: "include",
...init,
})
if (res.status === 401) throw new UnauthorizedError()
if (!res.ok) {
let msg = `HTTP ${res.status}`
try { const b = await res.json(); if (b?.error) msg = String(b.error) } catch { /* ignore */ }
@@ -19,29 +29,52 @@ async function req<T>(path: string, init?: RequestInit): Promise<T> {
return (await res.json()) as T
}
export const api = {
listAccounts: () => req<Account[]>("/accounts"),
createAccount: (input: CreateAccountInput) =>
req<Account>("/accounts", { method: "POST", body: JSON.stringify(input) }),
deleteAccount: (id: string) => req<void>(`/accounts/${id}`, { method: "DELETE" }),
listTemplates: () => req<Template[]>("/templates"),
createTemplate: (input: CreateTemplateInput) =>
req<Template>("/templates", { method: "POST", body: JSON.stringify(input) }),
updateTemplate: (id: string, input: CreateTemplateInput) =>
req<Template>(`/templates/${id}`, { method: "PUT", body: JSON.stringify(input) }),
deleteTemplate: (id: string) => req<void>(`/templates/${id}`, { method: "DELETE" }),
listDomains: () => req<Domain[]>("/domains"),
createDomain: (input: CreateDomainInput) =>
req<Domain>("/domains", { method: "POST", body: JSON.stringify(input) }),
deleteDomain: (id: string) => req<void>(`/domains/${id}`, { method: "DELETE" }),
importZones: (accountId: string) =>
req<Domain[]>(`/accounts/${accountId}/import`, { method: "POST" }),
setDomainTemplate: (id: string, templateId: string | null) =>
req<Domain>(`/domains/${id}`, { method: "PATCH", body: JSON.stringify({ templateId }) }),
checkDomain: (id: string) => req<ChangesetResponse>(`/domains/${id}/check`),
applyDomain: (id: string, body: ApplyRequest) =>
req<ChangesetResponse>(`/domains/${id}/apply`, { method: "POST", body: JSON.stringify(body) }),
function projectPath(projectId: string, path: string): string {
return `${API_ROOT}/projects/${projectId}${path}`
}
export const api = {
auth: {
register: (email: string, password: string) =>
req<AuthState>(`${API_ROOT}/auth/register`, {
method: "POST",
body: JSON.stringify({ email, password }),
}),
login: (email: string, password: string) =>
req<AuthState>(`${API_ROOT}/auth/login`, {
method: "POST",
body: JSON.stringify({ email, password }),
}),
logout: () => req<void>(`${API_ROOT}/auth/logout`, { method: "POST" }),
me: () => req<AuthState>(`${API_ROOT}/auth/me`),
},
listAccounts: (projectId: string) => req<Account[]>(projectPath(projectId, "/accounts")),
createAccount: (projectId: string, input: CreateAccountInput) =>
req<Account>(projectPath(projectId, "/accounts"), { method: "POST", body: JSON.stringify(input) }),
deleteAccount: (projectId: string, id: string) =>
req<void>(projectPath(projectId, `/accounts/${id}`), { method: "DELETE" }),
listTemplates: (projectId: string) => req<Template[]>(projectPath(projectId, "/templates")),
createTemplate: (projectId: string, input: CreateTemplateInput) =>
req<Template>(projectPath(projectId, "/templates"), { method: "POST", body: JSON.stringify(input) }),
updateTemplate: (projectId: string, id: string, input: CreateTemplateInput) =>
req<Template>(projectPath(projectId, `/templates/${id}`), { method: "PUT", body: JSON.stringify(input) }),
deleteTemplate: (projectId: string, id: string) =>
req<void>(projectPath(projectId, `/templates/${id}`), { method: "DELETE" }),
listDomains: (projectId: string) => req<Domain[]>(projectPath(projectId, "/domains")),
createDomain: (projectId: string, input: CreateDomainInput) =>
req<Domain>(projectPath(projectId, "/domains"), { method: "POST", body: JSON.stringify(input) }),
deleteDomain: (projectId: string, id: string) =>
req<void>(projectPath(projectId, `/domains/${id}`), { method: "DELETE" }),
importZones: (projectId: string, accountId: string) =>
req<Domain[]>(projectPath(projectId, `/accounts/${accountId}/import`), { method: "POST" }),
setDomainTemplate: (projectId: string, id: string, templateId: string | null) =>
req<Domain>(projectPath(projectId, `/domains/${id}`), { method: "PATCH", body: JSON.stringify({ templateId }) }),
checkDomain: (projectId: string, id: string) =>
req<ChangesetResponse>(projectPath(projectId, `/domains/${id}/check`)),
applyDomain: (projectId: string, id: string, body: ApplyRequest) =>
req<ChangesetResponse>(projectPath(projectId, `/domains/${id}/apply`), { method: "POST", body: JSON.stringify(body) }),
}
+4
View File
@@ -1,3 +1,7 @@
export interface User { id: string; email: string }
export interface Project { id: string; name: string }
export interface AuthState { user: User; project: Project }
export interface Account { id: string; provider: string; comment: string }
export interface CreateAccountInput { provider: string; secret: string; comment: string }