From 6a73d96778bcfb062f6c9a008bd5d2ec37205472 Mon Sep 17 00:00:00 2001 From: MT-Mint <798521692@qq.com> Date: Tue, 14 Apr 2026 10:42:47 +0800 Subject: [PATCH] =?UTF-8?q?test:=E6=B5=8B=E8=AF=95=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E6=97=B6=E7=A6=81=E6=AD=A2=E6=9A=B4=E9=9C=B2?= =?UTF-8?q?apikey=E7=9A=84=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../e2e/thinking-and-secret-leak.spec.ts | 296 ++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 frontend/tests/e2e/thinking-and-secret-leak.spec.ts diff --git a/frontend/tests/e2e/thinking-and-secret-leak.spec.ts b/frontend/tests/e2e/thinking-and-secret-leak.spec.ts new file mode 100644 index 00000000..d370b9e3 --- /dev/null +++ b/frontend/tests/e2e/thinking-and-secret-leak.spec.ts @@ -0,0 +1,296 @@ +import { expect, test, type Page } from "@playwright/test"; +import { v4 as uuid } from "uuid"; + +import { newChatEntry, openChat, sendMessage } from "./support/chat-helpers"; + +function logProgress(message: string) { + const timestamp = new Date().toISOString(); + // eslint-disable-next-line no-console + console.log(`[DF-SEC][${timestamp}] ${message}`); +} + +function parseForbiddenPrefixes() { + const raw = + process.env.FRONTEND_E2E_FORBIDDEN_UI_PREFIXES ?? + process.env.FRONTEND_E2E_FORBIDDEN_UI_PREFIX ?? + ""; + const prefixes = raw + .split(/[,\n]/g) + .map((item) => item.trim()) + .filter(Boolean); + return prefixes; +} + +async function assertNoForbiddenPrefixOnScreen( + page: Page, + prefixes: string[], +) { + if (prefixes.length === 0) return; + const leaked = await page.evaluate((items) => { + const text = document.body?.innerText ?? ""; + return items.some((prefix) => prefix && text.includes(prefix)); + }, prefixes); + + expect(leaked, "检测到敏感信息泄露到界面文本中").toBe(false); +} + +async function waitForConditionWithLeakCheck({ + page, + forbiddenPrefixes, + timeoutMs, + stepMs = 500, + label, + logEveryMs = 5_000, + condition, +}: { + page: Page; + forbiddenPrefixes: string[]; + timeoutMs: number; + stepMs?: number; + label?: string; + logEveryMs?: number; + condition: () => Promise; +}) { + const deadline = Date.now() + timeoutMs; + const start = Date.now(); + let lastLogAt = 0; + if (label) { + logProgress(`${label}… (timeout ${Math.round(timeoutMs / 1000)}s)`); + } + while (Date.now() < deadline) { + await assertNoForbiddenPrefixOnScreen(page, forbiddenPrefixes); + if (await condition()) return true; + if (label) { + const now = Date.now(); + if (now - lastLogAt >= logEveryMs) { + lastLogAt = now; + logProgress( + `${label}… (${Math.round((now - start) / 1000)}s elapsed)`, + ); + } + } + await page.waitForTimeout(stepMs); + } + await assertNoForbiddenPrefixOnScreen(page, forbiddenPrefixes); + return false; +} + +async function closeArtifactsPanelIfOpen(page: Page) { + const closeButton = page.getByTestId("artifacts-panel-close"); + if ((await closeButton.count()) === 0) return; + if (!(await closeButton.first().isVisible())) return; + await closeButton.first().click({ timeout: 10_000 }); +} + +async function openArtifactsPanelIfPossible(page: Page) { + const openButton = page.getByTestId("artifacts-open-button"); + if ((await openButton.count()) === 0) return false; + if (!(await openButton.first().isVisible())) return false; + await openButton.first().click({ timeout: 10_000 }); + return true; +} + +async function waitForArtifactCards({ + page, + forbiddenPrefixes, + timeoutMs, + minCount, + label, +}: { + page: Page; + forbiddenPrefixes: string[]; + timeoutMs: number; + minCount: number; + label: string; +}) { + const cards = page.getByTestId("artifact-file-card"); + const fileList = page.getByTestId("artifact-file-list"); + + const ok = await waitForConditionWithLeakCheck({ + page, + forbiddenPrefixes, + timeoutMs, + label, + condition: async () => { + // Cards only render when the panel is open. Try to open opportunistically. + if ((await fileList.count()) === 0 || !(await fileList.first().isVisible())) { + await openArtifactsPanelIfPossible(page); + } + if ((await cards.count()) < minCount) return false; + return await cards.first().isVisible(); + }, + }); + + return { ok, cards }; +} + +async function waitForComposerIdle({ + page, + forbiddenPrefixes, +}: { + page: Page; + forbiddenPrefixes: string[]; +}) { + const submit = page.locator("button[aria-label='Submit']"); + await waitForConditionWithLeakCheck({ + page, + forbiddenPrefixes, + timeoutMs: 30_000, + label: "Wait for composer idle", + condition: async () => { + if ((await submit.count()) === 0) return false; + const text = (await submit.first().innerText()).trim(); + // “停止”代表还在 streaming,避免在 streaming 态下发送新消息导致卡住/失败。 + return !/^(停止|Stop)$/i.test(text) && (await submit.first().isEnabled()); + }, + }); +} + +async function sendMessageSafely({ + page, + forbiddenPrefixes, + text, +}: { + page: Page; + forbiddenPrefixes: string[]; + text: string; +}) { + await closeArtifactsPanelIfOpen(page); + await waitForComposerIdle({ page, forbiddenPrefixes }); + await assertNoForbiddenPrefixOnScreen(page, forbiddenPrefixes); + + const textarea = page.locator("textarea[name='message']"); + await expect(textarea).toBeVisible({ timeout: 10_000 }); + // Avoid locator.click() flakiness when pointer-events are blocked by overlays: + // focus via DOM, then use keyboard to drive React controller updates. + await textarea.evaluate((element) => { + const target = element as HTMLTextAreaElement; + target.focus(); + }); + await textarea.evaluate((element) => { + const target = element as HTMLTextAreaElement; + const setter = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + "value", + )?.set; + setter?.call(target, ""); + target.dispatchEvent(new InputEvent("input", { bubbles: true })); + }); + await page.keyboard.insertText(text); + + const submit = page.locator("button[aria-label='Submit']"); + await expect + .poll( + async () => { + if ((await submit.count()) === 0) return "missing"; + const button = submit.first(); + const disabled = await button.evaluate( + (el) => (el as HTMLButtonElement).disabled, + ); + const label = (await button.innerText()).trim(); + const current = await textarea.evaluate( + (el) => (el as HTMLTextAreaElement).value, + ); + return disabled ? `disabled(label=${label},value=${current})` : label; + }, + { timeout: 20_000 }, + ) + .not.toMatch(/^disabled/); + + await submit.first().evaluate((button) => { + (button as HTMLButtonElement).click(); + }); +} + +// 按要求输出视频;同时关闭 screenshot/trace,降低敏感信息出现在测试产物中的概率。 +test.use({ screenshot: "off", video: "on", trace: "off" }); + +test.describe("安全 / 思考块与敏感信息泄露", () => { + test("DF-SEC-001 输入图像请求后 40s 内出现思考块,且界面不泄露系统 key 前缀", async ({ + page, + }, testInfo) => { + test.setTimeout(420_000); + + const forbiddenPrefixes = parseForbiddenPrefixes(); + if (forbiddenPrefixes.length === 0) { + throw new Error( + "缺少 FRONTEND_E2E_FORBIDDEN_UI_PREFIXES / FRONTEND_E2E_FORBIDDEN_UI_PREFIX 环境变量,无法执行泄露检测。", + ); + } + + const threadId = uuid(); + logProgress(`Open chat thread ${threadId.slice(0, 8)}…`); + await openChat(page, newChatEntry(threadId)); + await expect(page.getByTestId("welcome-suggestions")).toBeVisible(); + + logProgress("Send prompt: 生成图片…"); + await sendMessageSafely({ + page, + forbiddenPrefixes, + text: "帮我生成佐天泪子的图片", + }); + + // 不限制在单条 assistant 消息内:以 Chain-of-thought 容器出现 “steps” 作为信号。 + const stepsSignal = page + .locator(".not-prose.w-full.gap-2.rounded-lg.bg-white") + .locator("text=/steps/i"); + + const hasStepsSignal = await waitForConditionWithLeakCheck({ + page, + forbiddenPrefixes, + timeoutMs: 40_000, + label: "Wait for steps signal", + condition: async () => + (await stepsSignal.count()) > 0 && (await stepsSignal.first().isVisible()), + }); + + // 按需求:40s 内未出现思考块则中断后续检查(标记为 skip)。 + testInfo.skip(!hasStepsSignal, "40s 内未检测到 steps,按要求中断测试。"); + + logProgress("Steps signal found; waiting for artifact completion…"); + // 图片生成完成信号:出现 data-testid="artifact-file-card"(过程中会尽力自动打开 artifacts 面板)。 + const firstArtifacts = await waitForArtifactCards({ + page, + forbiddenPrefixes, + timeoutMs: 240_000, + minCount: 1, + label: "Wait for first artifact card", + }); + expect(firstArtifacts.ok, "未检测到 artifact-file-card,图片可能未生成完成").toBe( + true, + ); + logProgress( + `First artifact ready (count=${await firstArtifacts.cards.count()}).`, + ); + + // 图片生成完成后,再发送二次编辑指令,并继续检测是否有 key 泄露。 + const beforeSecondCount = await firstArtifacts.cards.count(); + logProgress("Send edit prompt: 把她的头发变成绿色的…"); + await sendMessageSafely({ + page, + forbiddenPrefixes, + text: "把她的头发变成绿色的", + }); + + const secondArtifacts = await waitForArtifactCards({ + page, + forbiddenPrefixes, + timeoutMs: 240_000, + minCount: beforeSecondCount + 1, + label: "Wait for second artifact card", + }); + expect(secondArtifacts.ok, "未检测到新的产物生成(artifact 数量未增加)").toBe(true); + logProgress( + `Second artifact ready (count=${await secondArtifacts.cards.count()}).`, + ); + + // 出现思考块后再额外观察一段时间,避免后续 stream 输出时才泄露。 + logProgress("Tail watch for secret leakage…"); + const tailDeadline = Date.now() + 10_000; + while (Date.now() < tailDeadline) { + await assertNoForbiddenPrefixOnScreen(page, forbiddenPrefixes); + await page.waitForTimeout(500); + } + logProgress("Done."); + }); +});