From b5d9e8f7ab74a163a9c0613c32532db74ee3642a Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Fri, 3 Jul 2026 21:00:18 +0700 Subject: [PATCH] =?UTF-8?q?feat(web):=20AuthContext=20+=20=D0=BA=D0=BB?= =?UTF-8?q?=D0=B8=D0=B5=D0=BD=D1=82=20=D0=BF=D0=BE=D0=B4=20cookie-=D1=81?= =?UTF-8?q?=D0=B5=D1=81=D1=81=D0=B8=D0=B8,=20projectId=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/api/client.test.ts | 105 +++++++++++++++++++++++++++--- web/src/api/client.ts | 87 +++++++++++++++++-------- web/src/api/types.ts | 4 ++ web/src/auth/AuthContext.test.tsx | 82 +++++++++++++++++++++++ web/src/auth/AuthContext.tsx | 74 +++++++++++++++++++++ web/src/hooks/useApi.ts | 84 ++++++++++++++++-------- web/src/lib/config.ts | 3 +- 7 files changed, 374 insertions(+), 65 deletions(-) create mode 100644 web/src/auth/AuthContext.test.tsx create mode 100644 web/src/auth/AuthContext.tsx diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index c894b9c..43a966f 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -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") + }) }) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 771dbce..748581d 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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(path: string, init?: RequestInit): Promise { - 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(path: string, init?: RequestInit): Promise { return (await res.json()) as T } -export const api = { - listAccounts: () => req("/accounts"), - createAccount: (input: CreateAccountInput) => - req("/accounts", { method: "POST", body: JSON.stringify(input) }), - deleteAccount: (id: string) => req(`/accounts/${id}`, { method: "DELETE" }), - - listTemplates: () => req("/templates"), - createTemplate: (input: CreateTemplateInput) => - req