Ctrl K
ComunidadGuías…

tests/e2e/mcp-permissions-diagnostics.spec.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { expect, test, fulfillJson } from "./helpers/run-checks-fixtures";
import { enableScopedE2EAuth, scopedPath } from "./helpers/auth";
import type { Page } from "@playwright/test";
import type { McpServer } from "@/components/settings/mcp-servers/types";
import { readToolPolicy, toolApproval } from "@/lib/mcp-servers/policy";
import type { DiagnosticCode } from "@/lib/mcp-servers/diagnostic-result";

async function setup(page: Page, extra: Record<string, unknown> = {}) {
  await enableScopedE2EAuth(page);
  let server: McpServer = {
    id: "11111111-1111-4111-8111-111111111111",
    name: "Documentation",
    enabled: true,
    transport: "http",
    command: null,
    args: [],
    envPlain: {},
    envSecretNames: [],
    url: "https://docs.example.com/mcp",
    headerPlain: {},
    headerSecretNames: ["Authorization"],
    extra,
    createdAt: "2026-09-22T01:00:00Z",
    updatedAt: "2026-09-22T01:00:00Z",
  };
  const saved: Record<string, unknown>[] = [];
  let testCount = 0;
  await page.route("**/api/mcp-servers", (route) =>
    fulfillJson(route, { servers: [server] })
  );
  await page.route(`**/api/mcp-servers/${server.id}`, async (route) => {
    const body = route.request().postDataJSON();
    saved.push(body);
    server = {
      ...server,
      ...body,
      updatedAt: new Date(Date.parse(server.updatedAt) + 1000).toISOString(),
    };
    await fulfillJson(route, { server });
  });
  await page.route(`**/api/mcp-servers/${server.id}/test`, async (route) => {
    testCount++;
    const policy = readToolPolicy(server.extra);
    await fulfillJson(route, {
      status: "success",
      enabled: server.enabled,
      checkedAt: new Date().toISOString(),
      serverUpdatedAt: server.updatedAt,
      tools: ["search", "publish", "delete"].map((name) => ({
        name,
        approval: toolApproval(policy, name),
      })),
    });
  });
  await page.goto(scopedPath("connections?tab=mcp"));
  return { saved, testCount: () => testCount };
}

/** The save request lands after the Save click resolves; wait for it. */
async function waitForSave(saved: Record<string, unknown>[], index: number) {
  await expect.poll(() => saved[index]).toBeDefined();
  return saved[index];
}

test("tests saved connections on demand and edits approval rules without losing saved restrictions or secrets", async ({
  page,
}) => {
  const fixture = await setup(page, {
    cwd: "/repo",
    disabled_tools: ["delete"],
    tools: { publish: { approval_mode: "prompt", custom: 17 } },
  });
  await expect(page.getByText("Not tested in this visit.")).toBeVisible();
  expect(fixture.testCount()).toBe(0);
  await page
    .getByRole("button", { name: "Test connection", exact: true })
    .click();
  await expect(
    page.getByText("Connection test passed. 3 tools found.")
  ).toBeVisible();
  await expect(
    page.getByText(
      "1 available in workspace chat. Control approval required: 1. Blocked: 1."
    )
  ).toBeVisible();
  await page.getByText("Discovered tools", { exact: true }).click();
  await expect(
    page.getByText("publish: Control only: approval required")
  ).toBeVisible();
  await page.getByRole("button", { name: "Edit", exact: true }).click();
  await expect(page.getByPlaceholder("Overwrite saved secret")).toHaveValue("");
  await page
    .getByRole("combobox", { name: "Default approval", exact: true })
    .click();
  await page
    .getByRole("option", { name: "Ask in Control", exact: true })
    .click();
  await page
    .getByRole("combobox", { name: "Approval for search", exact: true })
    .click();
  await page
    .getByRole("option", { name: "Run automatically", exact: true })
    .click();
  await page.getByRole("button", { name: "Save changes" }).click();
  await expect(page.getByText("Not tested in this visit.")).toBeVisible();
  expect(await waitForSave(fixture.saved, 0)).toMatchObject({
    headerSecrets: {},
    extra: {
      cwd: "/repo",
      disabled_tools: ["delete"],
      default_tools_approval_mode: "prompt",
      tools: {
        publish: { approval_mode: "prompt", custom: 17 },
        search: { approval_mode: "auto" },
      },
    },
  });
  await page.getByRole("button", { name: "Edit", exact: true }).click();
  await expect(
    page.getByRole("combobox", { name: "Default approval", exact: true })
  ).toHaveText("Ask in Control");
  await page.getByRole("button", { name: "Cancel", exact: true }).click();
  await page.reload();
  await expect(page.getByText("Not tested in this visit.")).toBeVisible();
  expect(fixture.testCount()).toBe(1);
});

test("allowlist editing supports keyboard newlines and never makes an empty allowlist permissive", async ({
  page,
}) => {
  const fixture = await setup(page);
  await page.getByRole("button", { name: "Edit", exact: true }).click();
  await page
    .getByRole("switch", { name: "Allow all tools unless blocked" })
    .click();
  await expect(
    page.getByLabel("Allowed tools (one name per line)")
  ).toHaveValue("");
  await page.getByRole("button", { name: "Save changes" }).click();
  expect(await waitForSave(fixture.saved, 0)).toMatchObject({
    extra: { enabled_tools: [] },
  });
  await page
    .getByRole("button", { name: "Test connection", exact: true })
    .click();
  await expect(
    page.getByText(
      "0 available in workspace chat. Control approval required: 0. Blocked: 3."
    )
  ).toBeVisible();
  await page.getByRole("button", { name: "Edit", exact: true }).click();
  const allowed = page.getByLabel("Allowed tools (one name per line)");
  await allowed.fill("search");
  await allowed.press("End");
  await allowed.press("Enter");
  await allowed.pressSequentially("publish");
  await expect(allowed).toHaveValue("search\npublish");
  await allowed.fill(" search \nsearch\n \npublish ");
  await page
    .getByLabel("Blocked tools (one name per line)")
    .fill(" publish \n\npublish");
  await page.getByRole("button", { name: "Save changes" }).click();
  expect(await waitForSave(fixture.saved, 1)).toMatchObject({
    extra: {
      enabled_tools: ["search", "publish"],
      disabled_tools: ["publish"],
    },
  });
});

test("Save works straight after editing an untidy tool list", async ({
  page,
}) => {
  // Tall enough that the dialog does not scroll, so any shrink moves Save.
  await page.setViewportSize({ width: 1280, height: 2400 });
  const fixture = await setup(page);
  await page.getByRole("button", { name: "Edit", exact: true }).click();
  await page
    .getByLabel("Blocked tools (one name per line)")
    .fill(" publish \n\n\n\n\n\n\npublish\n \ndelete");
  // Tool rows already show the list as it will be saved.
  await expect(
    page.getByRole("combobox", { name: "Approval for publish", exact: true })
  ).toHaveCount(1);
  const save = page.getByRole("button", { name: "Save changes" });
  await page
    .getByRole("dialog")
    .evaluate((dialog) =>
      Promise.all(
        dialog
          .getAnimations({ subtree: true })
          .map((animation) => animation.finished)
      )
    );
  const box = await save.boundingBox();
  expect(box).not.toBeNull();
  // Press and release where Save is, as a person does; the press moves focus
  // out of the list, which must not shift Save before the release.
  await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2);
  await page.mouse.down();
  await page.mouse.up();
  expect(await waitForSave(fixture.saved, 0)).toMatchObject({
    extra: { disabled_tools: ["publish", "delete"] },
  });
});

test("invalid saved permissions stay blocked and can be repaired without changing CLI settings", async ({
  page,
}) => {
  const fixture = await setup(page, { cwd: "/repo", enabled_tools: "all" });
  await page.getByRole("button", { name: "Edit", exact: true }).click();
  await expect(page.getByRole("alert")).toContainText(
    "Tool permissions are invalid"
  );
  await page.getByRole("button", { name: "Save changes" }).click();
  await expect(
    page.getByText("Correct the tool permissions before saving.")
  ).toBeVisible();
  expect(fixture.saved).toHaveLength(0);
  await page
    .getByLabel("Extra JSON", { exact: true })
    .fill('{"cwd":"/repo","enabled_tools":[]}');
  await page.getByRole("button", { name: "Save changes" }).click();
  expect(await waitForSave(fixture.saved, 0)).toMatchObject({
    extra: { cwd: "/repo", enabled_tools: [] },
  });
});

test("shows pending, failure and retry states without automatic tests", async ({
  page,
}) => {
  await setup(page);
  let resolveTest!: () => void;
  const responseReady = new Promise<void>((resolve) => {
    resolveTest = resolve;
  });
  let calls = 0;
  await page.route("**/api/mcp-servers/*/test", async (route) => {
    calls++;
    await responseReady;
    await fulfillJson(route, {
      status: "error",
      code: (calls === 1
        ? "authentication"
        : "settings_unavailable") satisfies DiagnosticCode,
      checkedAt: new Date().toISOString(),
      serverUpdatedAt: "2026-09-22T01:00:00Z",
    });
  });
  await page
    .getByRole("button", { name: "Test connection", exact: true })
    .click();
  await expect(
    page.getByRole("button", { name: "Testing connection..." })
  ).toBeDisabled();
  resolveTest();
  await expect(
    page.getByText(
      "The server rejected access. Check the saved authorization header and its permissions."
    )
  ).toBeVisible();
  expect(calls).toBe(1);
  await page
    .getByRole("button", { name: "Test connection", exact: true })
    .click();
  await expect(
    page.getByRole("button", { name: "Test connection", exact: true })
  ).toBeEnabled();
  expect(calls).toBe(2);
  await expect(
    page.getByText(
      "Mogplex could not read the saved connection. Try again later."
    )
  ).toBeVisible();
});

test("lets users add and remove tool approval overrides, and preserves disabled rules", async ({
  page,
}) => {
  const fixture = await setup(page, {
    tools: { search: { enabled: false, custom: true } },
  });
  await page.getByRole("button", { name: "Edit", exact: true }).click();
  await expect(
    page.getByRole("combobox", { name: "Approval for search" })
  ).toBeDisabled();
  await page.getByRole("switch", { name: "Enable search" }).click();
  await expect(
    page.getByRole("combobox", { name: "Approval for search" })
  ).toBeEnabled();
  await page.getByLabel("Tool name for approval override").fill("publish");
  await page.getByRole("button", { name: "Add tool rule" }).click();
  await page.getByRole("combobox", { name: "Approval for publish" }).click();
  await page.getByRole("option", { name: "Block", exact: true }).click();
  await page.getByRole("button", { name: "Save changes" }).click();
  expect(await waitForSave(fixture.saved, 0)).toMatchObject({
    extra: {
      tools: {
        search: { enabled: true, custom: true },
        publish: { approval_mode: "deny" },
      },
    },
  });
  await page.getByRole("button", { name: "Edit", exact: true }).click();
  await page.getByRole("combobox", { name: "Approval for publish" }).click();
  await page.getByRole("option", { name: "Use default", exact: true }).click();
  await page.getByRole("button", { name: "Save changes" }).click();
  expect(
    (
      (await waitForSave(fixture.saved, 1)).extra as {
        tools: { publish: object };
      }
    ).tools.publish
  ).toEqual({});
});

test("cancels an in-flight connection test when saved settings change", async ({
  page,
}) => {
  await setup(page);
  let release!: () => void;
  const ready = new Promise<void>((resolve) => {
    release = resolve;
  });
  await page.route("**/api/mcp-servers/*/test", async (route) => {
    await ready;
    await fulfillJson(route, {
      status: "success",
      enabled: true,
      tools: [],
      checkedAt: new Date().toISOString(),
      serverUpdatedAt: "2026-09-22T01:00:00Z",
    });
  });
  const ended = new Promise<string>((resolve) => {
    page.on("requestfailed", (request) => {
      if (request.url().endsWith("/test")) resolve("cancelled");
    });
    page.on("requestfinished", (request) => {
      if (request.url().endsWith("/test")) resolve("completed");
    });
  });
  await page
    .getByRole("button", { name: "Test connection", exact: true })
    .click();
  await expect(
    page.getByRole("button", { name: "Testing connection..." })
  ).toBeDisabled();
  await page.getByRole("button", { name: "Edit", exact: true }).click();
  await page.getByRole("button", { name: "Save changes" }).click();
  await expect(page.getByText("Not tested in this visit.")).toBeVisible();
  release();
  expect(await ended).toBe("cancelled");
  await expect(
    page.getByText("Connection test passed. 0 tools found.")
  ).not.toBeVisible();
});

for (const variant of [
  { name: "desktop-light", theme: "light", width: 1440, height: 1000 },
  { name: "desktop-dark", theme: "dark", width: 1440, height: 1000 },
  { name: "mobile-dark", theme: "dark", width: 390, height: 844 },
] as const) {
  test(`permissions and diagnostics remain usable in ${variant.name}`, async ({
    page,
  }) => {
    await page.setViewportSize({
      width: variant.width,
      height: variant.height,
    });
    await page.addInitScript(
      (theme) => localStorage.setItem("mogplex-theme", theme),
      variant.theme
    );
    await page.emulateMedia({ colorScheme: variant.theme });
    await page.route(/\/api\/settings(?:\?.*)?$/, (route) =>
      fulfillJson(route, { theme: variant.theme })
    );
    await setup(page, { default_tools_approval_mode: "prompt" });
    await expect(page.locator("html")).toHaveClass(
      new RegExp(`(^| )${variant.theme}( |$)`)
    );
    await page
      .getByRole("button", { name: "Test connection", exact: true })
      .click();
    await expect(
      page.getByText("Connection test passed. 3 tools found.")
    ).toBeVisible();
    await expect(page.locator("html")).toHaveClass(
      new RegExp(`(^| )${variant.theme}( |$)`)
    );
    await page.screenshot({
      path: `/tmp/mcp-controls-${variant.name}-diagnostics.png`,
      fullPage: true,
    });
    await page.getByRole("button", { name: "Edit", exact: true }).click();
    await page
      .getByRole("combobox", { name: "Default approval", exact: true })
      .scrollIntoViewIfNeeded();
    await expect(
      page.getByRole("combobox", { name: "Default approval", exact: true })
    ).toHaveText("Ask in Control");
    expect(
      await page
        .getByRole("dialog")
        .evaluate((element) => element.scrollWidth <= element.clientWidth)
    ).toBe(true);
    await page.screenshot({
      path: `/tmp/mcp-controls-${variant.name}-permissions.png`,
    });
    await page.getByRole("button", { name: "Cancel", exact: true }).click();
  });
}

test("reports disabled and empty servers without promising tools are available", async ({
  page,
}) => {
  await setup(page);
  await page.route("**/api/mcp-servers/*/test", (route) =>
    fulfillJson(route, {
      status: "success",
      enabled: false,
      tools: [],
      checkedAt: new Date().toISOString(),
      serverUpdatedAt: "2026-09-22T01:00:00Z",
    })
  );
  await page
    .getByRole("button", { name: "Test connection", exact: true })
    .click();
  await expect(
    page.getByText("Connection test passed. 0 tools found.")
  ).toBeVisible();
  await expect(
    page.getByText(
      "This server is disabled. Enable it to make its allowed tools available."
    )
  ).toBeVisible();
  await expect(
    page.getByText("The server did not advertise any tools.")
  ).toBeVisible();
});

test("handles unavailable, malformed, and stale test responses without displaying a success", async ({
  page,
}) => {
  await setup(page);
  for (const scenario of [
    { status: 401, body: {}, text: "Sign in again to test this connection." },
    {
      status: 404,
      body: {},
      text: "This server no longer exists. Refresh the page.",
    },
    { status: 503, body: {}, text: "The test could not complete. Try again." },
    {
      status: 200,
      body: { status: "success", tools: "invalid" },
      text: "The test could not complete. Check your connection and try again.",
    },
    {
      status: 200,
      body: {
        status: "success",
        tools: [],
        enabled: true,
        checkedAt: new Date().toISOString(),
        serverUpdatedAt: "2026-09-22T03:00:00Z",
      },
      text: "The saved settings changed. Refresh the page, then test again.",
    },
  ]) {
    await page.route("**/api/mcp-servers/*/test", (route) =>
      fulfillJson(route, scenario.body, scenario.status)
    );
    await page
      .getByRole("button", { name: "Test connection", exact: true })
      .click();
    await expect(page.getByText(scenario.text, { exact: true })).toBeVisible();
    await expect(page.getByText(/Connection test passed/)).toHaveCount(0);
  }
});