fix(clients): reject dot names and recover legacy clients (#7062)

Reject reserved dot-segment client names and recover legacy records through query aliases. Preserve token scopes and path identity precedence, and update the generated API reference.
This commit is contained in:
Benjamin Mitchell
2026-11-05 09:02:39 -08:00
committed by GitHub
parent 2541080c91
commit cd176faf4e
29 changed files with 895 additions and 17 deletions
+162 -1
View File
@@ -8722,6 +8722,48 @@
}
}
},
"/panel/api/clients/get": {
"get": {
"tags": [
"Clients"
],
"summary": "Fetch one client by email, including the inbound IDs and external config IDs it is attached to.",
"operationId": "get_panel_api_clients_get",
"description": "Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization.",
"parameters": [
{
"name": "email",
"in": "query",
"required": true,
"description": "Client email (unique identifier).",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/panel/api/clients/get/subId/{subId}": {
"get": {
"tags": [
@@ -8845,7 +8887,7 @@
],
"summary": "Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets are generated server-side when omitted, so callers can send only the universal fields.",
"operationId": "post_panel_api_clients_add",
"description": "Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `/0` entry collides with every address of its family another client holds, and only a `0.0.0.0/0` or `::/0` default route that is already stored (legacy peers) keeps nobody out. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.",
"description": "Client email must not be exactly `.` or `..`. Existing reserved-name clients can be read, renamed to a valid name, or deleted using the query-parameter /get, /update and /del aliases. Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `/0` entry collides with every address of its family another client holds, and only a `0.0.0.0/0` or `::/0` default route that is already stored (legacy peers) keeps nobody out. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.",
"requestBody": {
"required": true,
"content": {
@@ -9070,6 +9112,70 @@
}
}
},
"/panel/api/clients/update": {
"post": {
"tags": [
"Clients"
],
"summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload — supply the full set of fields you want to keep (the server replaces the row, it does not patch).",
"operationId": "post_panel_api_clients_update",
"description": "Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.",
"parameters": [
{
"name": "email",
"in": "query",
"required": true,
"description": "Current client email (unique identifier).",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object"
},
"example": {
"email": "alice@example.com",
"totalGB": 107374182400,
"expiryTime": 1767225600000,
"limitHwid": 2,
"tgId": 123456789,
"enable": true
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
},
"example": {
"success": true,
"msg": "Client updated"
}
}
}
}
}
}
},
"/panel/api/clients/del/{email}": {
"post": {
"tags": [
@@ -9125,6 +9231,61 @@
}
}
},
"/panel/api/clients/del": {
"post": {
"tags": [
"Clients"
],
"summary": "Delete a client by email. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.",
"operationId": "post_panel_api_clients_del",
"description": "Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have removed the client from the remaining inbounds; the client record is kept in that case, so re-running the call retries exactly the leftovers. The record and traffic rows are dropped after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the client already removed from every inbound.",
"parameters": [
{
"name": "email",
"in": "query",
"required": true,
"description": "Client email (unique identifier).",
"schema": {
"type": "string"
}
},
{
"name": "keepTraffic",
"in": "query",
"required": true,
"description": "Pass 1 to retain the xray_client_traffic row after deletion.",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
},
"example": {
"success": true,
"msg": "Client deleted"
}
}
}
}
}
}
},
"/panel/api/clients/{email}/attach": {
"post": {
"tags": [
+5
View File
@@ -0,0 +1,5 @@
export function clientManagementPath(action: 'get' | 'update' | 'del', email: string): string {
const base = `/panel/api/clients/${action}`;
const encoded = encodeURIComponent(email);
return email === '.' || email === '..' ? `${base}?email=${encoded}` : `${base}/${encoded}`;
}
+5 -5
View File
@@ -4,6 +4,7 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tansta
import { HttpUtil, Msg } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { keys } from '@/api/queryKeys';
import { clientManagementPath } from '@/api/client-management';
import { markLocalInvalidate } from '@/api/invalidationTracker';
import {
ClientHydrateSchema,
@@ -343,7 +344,7 @@ export function useClients(options: UseClientsOptions = {}) {
const hydrate = useCallback(async (email: string): Promise<ClientHydrate | null> => {
if (!email) return null;
const msg = await HttpUtil.get(`/panel/api/clients/get/${encodeURIComponent(email)}`);
const msg = await HttpUtil.get(clientManagementPath('get', email));
if (!msg?.success || !msg.obj) return null;
const validated = parseMsg(msg, ClientHydrateSchema, 'clients/get');
return validated.obj;
@@ -375,7 +376,7 @@ export function useClients(options: UseClientsOptions = {}) {
const updateMut = useMutation({
mutationFn: ({ email, client }: { email: string; client: unknown }) =>
HttpUtil.post(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS),
HttpUtil.post(clientManagementPath('update', email), client, JSON_HEADERS),
onSuccess: (msg) => {
if (msg?.success) invalidateAll();
},
@@ -383,9 +384,8 @@ export function useClients(options: UseClientsOptions = {}) {
const removeMut = useMutation({
mutationFn: ({ email, keepTraffic }: { email: string; keepTraffic?: boolean }) => {
const url = keepTraffic
? `/panel/api/clients/del/${encodeURIComponent(email)}?keepTraffic=1`
: `/panel/api/clients/del/${encodeURIComponent(email)}`;
let url = clientManagementPath('del', email);
if (keepTraffic) url += `${url.includes('?') ? '&' : '?'}keepTraffic=1`;
return HttpUtil.post(url);
},
onSuccess: (msg) => {
+50 -1
View File
@@ -1149,6 +1149,19 @@ export const sections: readonly Section[] = [
response:
'{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5],\n "externalLinks": [\n { "id": 11, "kind": "link", "value": "vless://...", "remark": "DE", "enable": true, "expiryTime": 0 },\n { "id": 12, "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider", "enable": false, "expiryTime": 1767225600000, "namePrefix": "[zjh] ", "lastFetchAt": 1767220000000, "lastFetchError": "" }\n ]\n }\n}',
},
{
method: 'GET',
path: '/panel/api/clients/get',
summary:
'Fetch one client by email, including the inbound IDs and external config IDs it is attached to.',
description:
'Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization.',
params: [
{ name: 'email', in: 'query', type: 'string', desc: 'Client email (unique identifier).' },
],
response:
'{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5],\n "externalLinks": [\n { "id": 11, "kind": "link", "value": "vless://...", "remark": "DE", "enable": true, "expiryTime": 0 },\n { "id": 12, "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider", "enable": false, "expiryTime": 1767225600000, "namePrefix": "[zjh] ", "lastFetchAt": 1767220000000, "lastFetchError": "" }\n ]\n }\n}',
},
{
method: 'GET',
path: '/panel/api/clients/get/subId/:subId',
@@ -1195,7 +1208,7 @@ export const sections: readonly Section[] = [
summary:
'Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets are generated server-side when omitted, so callers can send only the universal fields.',
description:
'Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `/0` entry collides with every address of its family another client holds, and only a `0.0.0.0/0` or `::/0` default route that is already stored (legacy peers) keeps nobody out. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.',
'Client email must not be exactly `.` or `..`. Existing reserved-name clients can be read, renamed to a valid name, or deleted using the query-parameter /get, /update and /del aliases. Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `/0` entry collides with every address of its family another client holds, and only a `0.0.0.0/0` or `::/0` default route that is already stored (legacy peers) keeps nobody out. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.',
params: [
{
name: 'client',
@@ -1277,6 +1290,24 @@ export const sections: readonly Section[] = [
body: '{\n "email": "alice@example.com",\n "totalGB": 107374182400,\n "expiryTime": 1767225600000,\n "limitHwid": 2,\n "tgId": 123456789,\n "enable": true\n}',
response: '{\n "success": true,\n "msg": "Client updated"\n}',
},
{
method: 'POST',
path: '/panel/api/clients/update',
summary:
'Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload — supply the full set of fields you want to keep (the server replaces the row, it does not patch).',
description:
'Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.',
params: [
{
name: 'email',
in: 'query',
type: 'string',
desc: 'Current client email (unique identifier).',
},
],
body: '{\n "email": "alice@example.com",\n "totalGB": 107374182400,\n "expiryTime": 1767225600000,\n "limitHwid": 2,\n "tgId": 123456789,\n "enable": true\n}',
response: '{\n "success": true,\n "msg": "Client updated"\n}',
},
{
method: 'POST',
path: '/panel/api/clients/del/:email',
@@ -1295,6 +1326,24 @@ export const sections: readonly Section[] = [
],
response: '{\n "success": true,\n "msg": "Client deleted"\n}',
},
{
method: 'POST',
path: '/panel/api/clients/del',
summary:
'Delete a client by email. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.',
description:
'Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have removed the client from the remaining inbounds; the client record is kept in that case, so re-running the call retries exactly the leftovers. The record and traffic rows are dropped after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the client already removed from every inbound.',
params: [
{ name: 'email', in: 'query', type: 'string', desc: 'Client email (unique identifier).' },
{
name: 'keepTraffic',
in: 'query',
type: 'integer',
desc: 'Pass 1 to retain the xray_client_traffic row after deletion.',
},
],
response: '{\n "success": true,\n "msg": "Client deleted"\n}',
},
{
method: 'POST',
path: '/panel/api/clients/:email/attach',
+1
View File
@@ -319,6 +319,7 @@ export const ClientFormSchema = z.object({
.string()
.trim()
.min(1, 'pages.clients.email')
.refine((v) => v !== '.' && v !== '..', 'pages.clients.emailReservedName')
.refine((v) => !hasForbiddenClientChars(v), 'pages.clients.emailInvalidChars'),
subId: z.string().refine((v) => !hasForbiddenClientChars(v), 'pages.clients.subIdInvalidChars'),
uuid: z.string(),
@@ -0,0 +1,120 @@
import type { ReactNode } from 'react';
import { act, renderHook } from '@testing-library/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { setupHttp } from '@/api/http-init';
import { useClients } from '@/hooks/useClients';
import { makeTestQueryClient } from '@/test/test-utils';
import { HttpUtil } from '@/utils';
const ORIGIN = 'https://example.invalid';
const BASE = '/audit/panel/api/clients/';
describe('legacy dot-name client recovery', () => {
const requests: { url: URL; method: string; body: unknown }[] = [];
beforeAll(() => {
vi.mocked(HttpUtil.get).mockRestore();
vi.mocked(HttpUtil.post).mockRestore();
});
beforeEach(() => {
requests.length = 0;
window.X_UI_BASE_PATH = `${ORIGIN}/audit/`;
setupHttp();
vi.stubGlobal(
'fetch',
vi.fn(async (input: string, init?: RequestInit) => {
const url = new URL(input);
const method = init?.method ?? 'GET';
if (!url.pathname.startsWith(BASE)) {
const obj = url.pathname.endsWith('/csrf-token') ? 'audit-csrf' : {};
return Response.json({ success: true, obj });
}
if (url.pathname.endsWith('/options') || url.pathname.endsWith('/onlines')) {
return Response.json({ success: true, obj: [] });
}
const body = init?.body ? JSON.parse(String(init.body)) : null;
requests.push({ url, method, body });
const [action, segment] = url.pathname.slice(BASE.length).split('/');
const email =
url.searchParams.get('email') ?? (segment ? decodeURIComponent(segment) : null);
if (action === 'get' && email) {
return Response.json({
success: true,
obj: { client: { email, uuid: 'kept-uuid' }, inboundIds: [7] },
});
}
if (['update', 'del'].includes(action) && email) {
return Response.json({ success: true });
}
return Response.json({ success: false, msg: 'Missing client identity' }, { status: 404 });
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
delete window.X_UI_BASE_PATH;
setupHttp();
});
function wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={makeTestQueryClient()}>{children}</QueryClientProvider>;
}
it.each(['.', '..'])('loads the full record and renames a legacy %j client', async (email) => {
const { result } = renderHook(() => useClients({ list: false }), { wrapper });
await act(async () => {
const full = await result.current.hydrate(email);
expect(full?.client.uuid).toBe('kept-uuid');
expect(full?.inboundIds).toEqual([7]);
const msg = await result.current.update(email, {
email: 'restored-client',
id: full?.client.uuid,
});
expect(msg.success).toBe(true);
});
const update = requests.find((r) => r.url.pathname === `${BASE}update`);
expect(update?.url.searchParams.get('email')).toBe(email);
expect(update?.body).toEqual({ email: 'restored-client', id: 'kept-uuid' });
});
it.each(['.', '..'])(
'individually deletes legacy %j while preserving keepTraffic',
async (email) => {
const { result } = renderHook(() => useClients({ list: false }), { wrapper });
await act(async () => {
const msg = await result.current.remove(email, true);
expect(msg.success).toBe(true);
});
const request = requests.find((r) => r.url.pathname === `${BASE}del`);
expect(request?.method).toBe('POST');
expect(request?.url.searchParams.get('email')).toBe(email);
expect(request?.url.searchParams.get('keepTraffic')).toBe('1');
},
);
it.each(['...', '.alice', 'alice.', 'alice.example', 'alice..example'])(
'keeps the existing path endpoints for %s',
async (email) => {
const { result } = renderHook(() => useClients({ list: false }), { wrapper });
await act(async () => {
expect((await result.current.hydrate(email))?.client.email).toBe(email);
expect((await result.current.update(email, { email })).success).toBe(true);
expect((await result.current.remove(email, true)).success).toBe(true);
});
for (const action of ['get', 'update', 'del']) {
const request = requests.find((r) => r.url.pathname === `${BASE}${action}/${email}`);
expect(request).toBeDefined();
expect(request?.url.searchParams.has('email')).toBe(false);
}
expect(
requests
.find((r) => r.url.pathname === `${BASE}del/${email}`)
?.url.searchParams.get('keepTraffic'),
).toBe('1');
},
);
});
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { ClientCreateFormSchema, ClientFormSchema } from '@/schemas/client';
describe('client name validation', () => {
it.each(['.', '..', ' . ', ' .. '])('rejects the reserved name %j on create and edit', (name) => {
for (const schema of [ClientCreateFormSchema, ClientFormSchema]) {
const result = schema.shape.email.safeParse(name);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe('pages.clients.emailReservedName');
}
}
});
it.each(['...', '.alice', 'alice.', 'alice.example', 'alice..example', 'alice@example.com'])(
'preserves ordinary dotted names: %s',
(name) => {
expect(ClientFormSchema.shape.email.parse(name)).toBe(name);
},
);
});