main
ts 291 lines 8.58 KB
Raw
1 import { act, renderHook, waitFor } from "@testing-library/react";
2 import { beforeEach, describe, expect, it, vi } from "vitest";
3
4 import type { PolicyLease, PolicySettings } from "@/types/api";
5 import { useAdmin } from "@/hooks/useAdmin";
6 import { BROWSER_API_PATHS } from "@/lib/apiPaths";
7 import { APIClientError, apiClient } from "@/lib/apiClient";
8
9 type DeferredPolicyState = {
10 leases: PolicyLease[];
11 policy: PolicySettings;
12 };
13
14 vi.mock("@/hooks/useList", () => ({
15 useList: vi.fn(() => ({
16 searchQuery: "",
17 status: "all",
18 sortBy: "default",
19 selectedTags: [],
20 favorites: [],
21 availableTags: [],
22 filteredServers: [],
23 handleSearchChange: vi.fn(),
24 handleStatusChange: vi.fn(),
25 handleSortByChange: vi.fn(),
26 handleTagToggle: vi.fn(),
27 handleToggleFavorite: vi.fn(),
28 })),
29 }));
30
31 vi.mock("@/lib/apiClient", async () => {
32 const actual = await vi.importActual<typeof import("@/lib/apiClient")>(
33 "@/lib/apiClient",
34 );
35
36 return {
37 ...actual,
38 apiClient: {
39 get: vi.fn(),
40 post: vi.fn(),
41 },
42 };
43 });
44
45 function buildSettings(approvalMode: "auto" | "manual" = "auto"): PolicySettings {
46 return {
47 approval_mode: approvalMode,
48 landing_page_enabled: false,
49 udp: { enabled: false, max_leases: 0 },
50 tcp_port: { enabled: false, max_leases: 0 },
51 };
52 }
53
54 function buildLease(address: string, name: string = "relay-1"): PolicyLease {
55 return {
56 expires_at: "2026-03-04T00:00:00Z",
57 first_seen_at: "2026-03-02T00:00:00Z",
58 last_seen_at: "2026-03-03T00:00:00Z",
59 identity_key: `${name.toLowerCase()}:${address.toLowerCase()}`,
60 address,
61 name,
62 bps: 1024,
63 client_ip: "203.0.113.10",
64 reported_ip: "",
65 hostname: "relay.example.com",
66 metadata: {
67 description: "relay",
68 tags: ["core"],
69 thumbnail: "",
70 owner: "ops",
71 },
72 ready: 1,
73 is_approved: true,
74 is_banned: address === "0x00000000000000000000000000000000000000A1",
75 is_denied: false,
76 is_ip_banned: false,
77 };
78 }
79
80 async function waitForLoaded(result: { current: { loading: boolean } }) {
81 await waitFor(() => {
82 expect(result.current.loading).toBe(false);
83 });
84 }
85
86 describe("useAdmin", () => {
87 const mockGet = vi.mocked(apiClient.get);
88 const mockPost = vi.mocked(apiClient.post);
89
90 beforeEach(() => {
91 vi.clearAllMocks();
92
93 mockGet.mockImplementation(async (path: string) => {
94 if (path === BROWSER_API_PATHS.policy.state) {
95 return {
96 leases: [buildLease("0x00000000000000000000000000000000000000A1")],
97 policy: { ...buildSettings(), approval_mode: "not-a-mode" },
98 } as never;
99 }
100 throw new Error(`Unexpected GET path: ${path}`);
101 });
102
103 mockPost.mockImplementation(async <T,>(path: string, body?: unknown): Promise<T> => {
104 if (path === BROWSER_API_PATHS.policy.root) {
105 return body as T;
106 }
107 return {} as T;
108 });
109 });
110
111 it("normalizes fetchData results on success", async () => {
112 const { result } = renderHook(() => useAdmin());
113
114 await waitForLoaded(result);
115
116 expect(result.current.error).toBe("");
117 expect(result.current.approvalMode).toBe("auto");
118 expect(result.current.servers[0]?.address).toBe("0x00000000000000000000000000000000000000A1");
119 expect(result.current.servers[0]?.isBanned).toBe(true);
120 expect(result.current.servers[0]?.bps).toBe(1024);
121 });
122
123 it("surfaces fetchData API errors", async () => {
124 mockGet.mockImplementation(async (path: string) => {
125 if (path === BROWSER_API_PATHS.policy.state) {
126 throw new APIClientError("failed to load leases", 500, "server_error");
127 }
128 throw new Error(`Unexpected GET path: ${path}`);
129 });
130
131 const { result } = renderHook(() => useAdmin());
132
133 await waitForLoaded(result);
134
135 expect(result.current.error).toBe("failed to load leases");
136 });
137
138 it("maps contract error codes to resilient admin messages", async () => {
139 const { result } = renderHook(() => useAdmin());
140 await waitForLoaded(result);
141
142 mockPost.mockRejectedValueOnce(
143 new APIClientError("request failed", 400, "invalid_mode"),
144 );
145
146 await act(async () => {
147 await expect(result.current.handleApprovalModeChange("manual")).rejects.toBeInstanceOf(
148 APIClientError,
149 );
150 });
151
152 await waitFor(() => {
153 expect(result.current.error).toBe(
154 "Invalid approval mode. Choose auto or manual and retry.",
155 );
156 });
157 });
158
159 it("validates missing IP in handleIPBanStatus", async () => {
160 const { result } = renderHook(() => useAdmin());
161 await waitForLoaded(result);
162
163 await act(async () => {
164 await expect(result.current.handleIPBanStatus(" ", true)).rejects.toThrow(
165 "Missing IP address",
166 );
167 });
168 await waitFor(() => {
169 expect(result.current.error).toContain("Missing IP address");
170 });
171 });
172
173 it("posts identity keys in lease policy bodies", async () => {
174 const { result } = renderHook(() => useAdmin());
175 await waitForLoaded(result);
176 const identityKey = "relay-1:0x00000000000000000000000000000000000000a1";
177
178 await act(async () => {
179 await result.current.handleApproveStatus(identityKey, true);
180 });
181
182 const calledPaths = mockPost.mock.calls.map(([path]) => path as string);
183 expect(calledPaths).toContain(BROWSER_API_PATHS.policy.leases);
184 expect(mockPost).toHaveBeenCalledWith(BROWSER_API_PATHS.policy.leases, {
185 identity_key: identityKey,
186 is_approved: true,
187 });
188 });
189
190 it("posts bps updates to the lease policy endpoint", async () => {
191 const { result } = renderHook(() => useAdmin());
192 await waitForLoaded(result);
193
194 await act(async () => {
195 await result.current.handleBPSChange(
196 "relay-1:0x00000000000000000000000000000000000000a1",
197 4096
198 );
199 });
200
201 expect(mockPost).toHaveBeenCalledWith(
202 BROWSER_API_PATHS.policy.leases,
203 {
204 identity_key: "relay-1:0x00000000000000000000000000000000000000a1",
205 bps: 4096,
206 },
207 );
208 });
209
210 it("keeps loading false while refreshing bps in the background", async () => {
211 let getCalls = 0;
212 let resolveRefresh:
213 | ((value: DeferredPolicyState | PromiseLike<DeferredPolicyState>) => void)
214 | undefined;
215
216 mockGet.mockImplementation((path: string) => {
217 if (path !== BROWSER_API_PATHS.policy.state) {
218 throw new Error(`Unexpected GET path: ${path}`);
219 }
220 getCalls++;
221 if (getCalls === 1) {
222 return Promise.resolve({
223 leases: [buildLease("0x00000000000000000000000000000000000000A1")],
224 policy: buildSettings(),
225 } as never);
226 }
227 return new Promise<DeferredPolicyState>((resolve) => {
228 resolveRefresh = resolve;
229 }) as never;
230 });
231
232 const { result } = renderHook(() => useAdmin());
233 await waitForLoaded(result);
234
235 let pending: Promise<void> | undefined;
236 await act(async () => {
237 pending = result.current.handleBPSChange(
238 "relay-1:0x00000000000000000000000000000000000000a1",
239 2048
240 );
241 await Promise.resolve();
242 expect(result.current.loading).toBe(false);
243 resolveRefresh?.({
244 leases: [{ ...buildLease("0x00000000000000000000000000000000000000A1"), bps: 2048 }],
245 policy: buildSettings(),
246 });
247 await pending;
248 });
249
250 expect(result.current.servers[0]?.bps).toBe(2048);
251 });
252
253 it("bulk deny posts deduped identity keys in lease policy bodies", async () => {
254 mockGet.mockImplementation(async (path: string) => {
255 if (path === BROWSER_API_PATHS.policy.state) {
256 return {
257 leases: [
258 buildLease("0x00000000000000000000000000000000000000A1", "relay-1"),
259 buildLease("0x00000000000000000000000000000000000000B2", "relay-2"),
260 ],
261 policy: buildSettings(),
262 } as never;
263 }
264 throw new Error(`Unexpected GET path: ${path}`);
265 });
266
267 const { result } = renderHook(() => useAdmin());
268 await waitForLoaded(result);
269 const identityKeyA = "relay-1:0x00000000000000000000000000000000000000a1";
270 const identityKeyB = "relay-2:0x00000000000000000000000000000000000000b2";
271
272 await act(async () => {
273 await result.current.handleBulkDeny([
274 identityKeyA,
275 identityKeyA,
276 identityKeyB,
277 ]);
278 });
279
280 const denyCalls = mockPost.mock.calls.filter(([, body]) => {
281 return (body as { is_denied?: boolean }).is_denied === true;
282 });
283 expect(denyCalls).toHaveLength(2);
284 expect(denyCalls).toEqual(
285 expect.arrayContaining([
286 [BROWSER_API_PATHS.policy.leases, { identity_key: identityKeyA, is_denied: true }],
287 [BROWSER_API_PATHS.policy.leases, { identity_key: identityKeyB, is_denied: true }],
288 ]),
289 );
290 });
291 });