Unverified Commit aa73a8ca authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(panel): 建服表单按后端规则即时校验名称和子域名,控制台命令历史的下键不再跳到最早一条,补请求体测试

parent 6943d9c4
Loading
Loading
Loading
Loading
+43 −0
Changes for internal/naming/panel_mirror_test.go: 43 added lines, 0 removed lines.
Original line number Diff line number Diff line
package naming

import (
	"os"
	"regexp"
	"slices"
	"strings"
	"testing"
)

// The panel's create form checks names as they are typed with its own copy of
// this rule (panel/src/lib/naming.ts). A name reserved or a length changed here
// without the panel would let the form send what the API refuses, or refuse what
// it takes, so the copy is compared to the source.
func TestPanelMirrorsServerNameRule(t *testing.T) {
	raw, err := os.ReadFile("../../panel/src/lib/naming.ts")
	if err != nil {
		t.Fatal(err)
	}
	src := string(raw)

	list := regexp.MustCompile(`(?s)RESERVED_SERVER_NAMES: readonly string\[\] = \[(.*?)\];`).FindStringSubmatch(src)
	if list == nil {
		t.Fatal("panel/src/lib/naming.ts: RESERVED_SERVER_NAMES not found")
	}
	var panel []string
	for _, m := range regexp.MustCompile(`"([^"]*)"`).FindAllStringSubmatch(list[1], -1) {
		panel = append(panel, m[1])
	}
	var api []string
	for name := range reserved {
		api = append(api, name)
	}
	slices.Sort(panel)
	slices.Sort(api)
	if !slices.Equal(panel, api) {
		t.Errorf("panel reserves %v, API reserves %v", panel, api)
	}

	if want := "const SERVER_NAME_RE = /" + serverNameRE.String() + "/;"; !strings.Contains(src, want) {
		t.Errorf("panel/src/lib/naming.ts lacks %q", want)
	}
}
+181 −0
Changes for panel/src/components/CreateServerDialog.test.tsx: 181 added lines, 0 removed lines.
Original line number Diff line number Diff line
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CreateServerDialog } from "./CreateServerDialog";
import { humanizeError } from "@/lib/api";
import type { CreateServerRequest, WhitelistImage } from "@/lib/types";

const calls = vi.hoisted(() => ({ listImages: vi.fn(), createServer: vi.fn() }));
vi.mock("@/lib/api", async (importActual) => {
  const actual = await importActual<typeof import("@/lib/api")>();
  return { ...actual, api: { ...actual.api, ...calls } };
});

// Radix Select opens with pointer capture and scrolls the picked item into view,
// neither of which jsdom implements.
beforeAll(() => {
  Element.prototype.hasPointerCapture ??= () => false;
  Element.prototype.releasePointerCapture ??= () => {};
  Element.prototype.scrollIntoView ??= () => {};
});

const PAPER = "registry.example.test/paper:1.21";
const RETIRED = "registry.example.test/paper:1.20";
const cfg = { apiBase: "/api/v1", rootDomain: "example.test", gamePort: 25570 };
const onCreated = vi.fn();

function image(image_ref: string, enabled: boolean): WhitelistImage {
  return { image_ref, enabled } as WhitelistImage;
}

beforeEach(() => {
  calls.listImages.mockReset();
  calls.createServer.mockReset();
  onCreated.mockReset();
  calls.listImages.mockResolvedValue([image(PAPER, true), image(RETIRED, false)]);
  calls.createServer.mockResolvedValue({});
});

async function openDialog() {
  const user = userEvent.setup();
  render(<CreateServerDialog cfg={cfg} onCreated={onCreated} />);
  await user.click(screen.getByRole("button", { name: "New server" }));
  await screen.findByRole("dialog");
  return user;
}

const nameBox = () => screen.getByLabelText("Name") as HTMLInputElement;
const subdomainBox = () => screen.getByLabelText("Subdomain") as HTMLInputElement;
const create = () => screen.getByRole("button", { name: "Create" }) as HTMLButtonElement;

async function pick(user: ReturnType<typeof userEvent.setup>, field: string, option: string) {
  await user.click(screen.getByRole("combobox", { name: field }));
  await user.click(await screen.findByRole("option", { name: option }));
}

/** fillValid fills every required field with what the API takes. */
async function fillValid(user: ReturnType<typeof userEvent.setup>) {
  await user.type(nameBox(), "survival");
  await user.type(subdomainBox(), "survival");
  await pick(user, "Image", PAPER);
}

function sent(): CreateServerRequest {
  expect(calls.createServer).toHaveBeenCalledTimes(1);
  return calls.createServer.mock.calls[0][0];
}

describe("CreateServerDialog request body", () => {
  it("sends every field as chosen, lowercased and trimmed", async () => {
    const user = await openDialog();

    await user.type(nameBox(), "Survival-2");
    await user.type(subdomainBox(), "SMP");
    await user.type(screen.getByLabelText("Display name (optional)"), "  Survival World  ");
    await pick(user, "Image", PAPER);
    await pick(user, "Memory", "8Gi");
    await pick(user, "Storage", "20Gi");
    await pick(user, "Autostart policy", "Public — any player join wakes it");
    await user.click(create());

    expect(sent()).toStrictEqual({
      name: "survival-2",
      subdomain: "smp",
      displayName: "Survival World",
      image: PAPER,
      memory: "8Gi",
      storage: "20Gi",
      autostartPolicy: "public",
    });
    expect(onCreated).toHaveBeenCalledTimes(1);
    expect(screen.queryByRole("dialog")).toBeNull();
  });

  it("leaves a blank display name out, and sends the default sizes and policy", async () => {
    const user = await openDialog();

    await fillValid(user);
    await user.type(screen.getByLabelText("Display name (optional)"), "   ");
    await user.click(create());

    expect(sent()).toStrictEqual({
      name: "survival",
      subdomain: "survival",
      displayName: undefined,
      image: PAPER,
      memory: "4Gi",
      storage: "10Gi",
      autostartPolicy: "ownerOnly",
    });
  });

  it("offers only the enabled images", async () => {
    const user = await openDialog();

    await user.click(screen.getByRole("combobox", { name: "Image" }));
    const options = await screen.findAllByRole("option");
    expect(options.map((o) => o.textContent)).toEqual([PAPER]);
  });

  it("keeps the form and says why when the API refuses, then creates on the next try", async () => {
    const refusal = { status: 409, code: "conflict", message: "server survival already exists" };
    calls.createServer.mockRejectedValueOnce(refusal);
    const user = await openDialog();

    await fillValid(user);
    await user.click(create());

    const dialog = screen.getByRole("dialog");
    expect(within(dialog).getByText(humanizeError(refusal))).toBeTruthy();
    expect(nameBox().value).toBe("survival");
    expect(onCreated).not.toHaveBeenCalled();

    await user.click(create());
    expect(calls.createServer).toHaveBeenCalledTimes(2);
    expect(onCreated).toHaveBeenCalledTimes(1);
  });
});

describe("CreateServerDialog names", () => {
  it.each([
    ["ab", "Use 3–32 lowercase letters, digits or hyphens, with no hyphen at either end."],
    ["-survival", "Use 3–32 lowercase letters, digits or hyphens, with no hyphen at either end."],
    ["survival-", "Use 3–32 lowercase letters, digits or hyphens, with no hyphen at either end."],
    ["my_world", "Use 3–32 lowercase letters, digits or hyphens, with no hyphen at either end."],
    ["a".repeat(33), "Use 3–32 lowercase letters, digits or hyphens, with no hyphen at either end."],
    ["lobby", "“lobby” is kept for the platform. Pick another."],
  ])("refuses the name %j before sending, and says why", async (name, why) => {
    const user = await openDialog();
    await fillValid(user);
    expect(create().disabled).toBe(false);

    await user.clear(nameBox());
    await user.type(nameBox(), name);

    expect(create().disabled).toBe(true);
    expect(nameBox().getAttribute("aria-invalid")).toBe("true");
    expect(screen.getByText(why)).toBeTruthy();
  });

  it("holds the subdomain to the same rule", async () => {
    const user = await openDialog();
    await fillValid(user);

    await user.clear(subdomainBox());
    await user.type(subdomainBox(), "api");

    expect(create().disabled).toBe(true);
    expect(screen.getByText("“api” is kept for the platform. Pick another.")).toBeTruthy();
    expect(screen.queryByText(/Will be reachable at/)).toBeNull();
  });

  it("shows where a good subdomain will be reachable", async () => {
    const user = await openDialog();

    await user.type(subdomainBox(), "smp");

    expect(screen.getByText(/Will be reachable at smp\.example\.test/)).toBeTruthy();
    expect(subdomainBox().getAttribute("aria-invalid")).toBe("false");
  });
});
+24 −9
Changes for panel/src/components/CreateServerDialog.tsx: 24 added lines, 9 removed lines.
Original line number Diff line number Diff line
@@ -23,6 +23,7 @@ import { Label } from "@/components/ui/label";
import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
import { hostFor, type RuntimeConfig } from "@/lib/config";
import { serverNameIssue, type ServerNameIssue } from "@/lib/naming";
import type { AutostartPolicy, CreateServerRequest } from "@/lib/types";
import { InlineError } from "@/components/MessageLine";

@@ -35,8 +36,6 @@ import { InlineError } from "@/components/MessageLine";
const MEMORY_OPTIONS = ["2Gi", "4Gi", "6Gi", "8Gi"];
const STORAGE_OPTIONS = ["5Gi", "10Gi", "20Gi", "50Gi"];

const SUBDOMAIN_RE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/;

function policyOptions(t: (key: string) => string): { value: AutostartPolicy; label: string }[] {
  return [
    { value: "ownerOnly", label: t("create_server_policy_owner") },
@@ -72,10 +71,11 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
  }

  const enabledImages = (images.data ?? []).filter((i) => i.enabled);
  const subdomainValid = SUBDOMAIN_RE.test(form.subdomain);
  const nameIssue = serverNameIssue(form.name);
  const subdomainIssue = serverNameIssue(form.subdomain);
  const canSubmit =
    !!form.name &&
    subdomainValid &&
    nameIssue === null &&
    subdomainIssue === null &&
    !!form.image &&
    !!form.memory &&
    !!form.storage &&
@@ -102,6 +102,12 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {

  const policies = policyOptions(t);

  // A field's hint turns into the reason it would be refused once something is typed.
  const issueText = (issue: ServerNameIssue, value: string) =>
    issue === "reserved" ? t("create_server_name_reserved", { name: value }) : t("create_server_name_invalid");
  const hintClass = (value: string, issue: ServerNameIssue | null) =>
    value && issue ? "text-xs text-destructive" : "text-xs text-muted-foreground";

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
@@ -125,8 +131,15 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
              id="cs-name"
              placeholder={t("create_server_name_placeholder")}
              value={form.name}
              onChange={(e) => set("name", e.target.value)}
              onChange={(e) => set("name", e.target.value.toLowerCase())}
              aria-invalid={!!form.name && nameIssue !== null}
              aria-describedby="cs-name-hint"
            />
            <p id="cs-name-hint" className={hintClass(form.name, nameIssue)}>
              {form.name && nameIssue
                ? issueText(nameIssue, form.name)
                : t("create_server_name_hint")}
            </p>
          </div>

          <div className="grid gap-2">
@@ -136,10 +149,12 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
              placeholder={t("create_server_subdomain_placeholder")}
              value={form.subdomain}
              onChange={(e) => set("subdomain", e.target.value.toLowerCase())}
              aria-invalid={!!form.subdomain && subdomainIssue !== null}
              aria-describedby="cs-sub-hint"
            />
            <p className="text-xs text-muted-foreground">
              {form.subdomain && !subdomainValid
                ? t("create_server_subdomain_invalid")
            <p id="cs-sub-hint" className={hintClass(form.subdomain, subdomainIssue)}>
              {form.subdomain && subdomainIssue
                ? issueText(subdomainIssue, form.subdomain)
                : t("create_server_subdomain_hint", { host: hostFor(form.subdomain || "name", cfg) })}
            </p>
          </div>
+4 −1
Changes for panel/src/i18n/resources/en-US/servers.json: 4 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -41,6 +41,7 @@
  "console_offline_title": "Console is offline",
  "console_offline_body": "The live console attaches automatically as soon as the server is running.",
  "my_servers_breadcrumb": "My servers",
  "command_label": "Server command",
  "command_placeholder": "Type a command… e.g. list",
  "log_connecting": "Connecting…",
  "log_live": "Live",
@@ -132,9 +133,11 @@
  "create_server_desc": "Pick from whitelisted images and sizes — the platform provisions the rest. No raw cluster config is exposed here.",
  "create_server_name": "Name",
  "create_server_name_placeholder": "survival",
  "create_server_name_hint": "3–32 lowercase letters, digits or hyphens.",
  "create_server_name_invalid": "Use 3–32 lowercase letters, digits or hyphens, with no hyphen at either end.",
  "create_server_name_reserved": "“{{name}}” is kept for the platform. Pick another.",
  "create_server_subdomain": "Subdomain",
  "create_server_subdomain_placeholder": "survival",
  "create_server_subdomain_invalid": "Lowercase letters, digits and hyphens only.",
  "create_server_subdomain_hint": "Will be reachable at {{host}}",
  "create_server_display_name": "Display name (optional)",
  "create_server_display_name_placeholder": "Survival World",
+4 −1
Changes for panel/src/i18n/resources/zh-CN/servers.json: 4 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -41,6 +41,7 @@
  "console_offline_title": "控制台未连接",
  "console_offline_body": "服务器运行后,实时控制台将自动连接。",
  "my_servers_breadcrumb": "我的服务器",
  "command_label": "服务器命令",
  "command_placeholder": "输入命令…如 list",
  "log_connecting": "连接中…",
  "log_live": "实时",
@@ -132,9 +133,11 @@
  "create_server_desc": "从白名单镜像及规格中选择,平台自动处理其余配置。不暴露原始集群配置。",
  "create_server_name": "名称",
  "create_server_name_placeholder": "survival",
  "create_server_name_hint": "3–32 位小写字母、数字或连字符。",
  "create_server_name_invalid": "请用 3–32 位小写字母、数字或连字符,首尾不能是连字符。",
  "create_server_name_reserved": "“{{name}}” 是平台保留的名字,请换一个。",
  "create_server_subdomain": "子域名",
  "create_server_subdomain_placeholder": "survival",
  "create_server_subdomain_invalid": "仅支持小写字母、数字和连字符。",
  "create_server_subdomain_hint": "访问地址:{{host}}",
  "create_server_display_name": "显示名称(选填)",
  "create_server_display_name_placeholder": "生存世界",
Loading