oh-my-muse/muse-studio/e2e/handoff-knowledge.spec.ts
lili 86bdba7974 test(handoff): P1 knowledge 兑现端到端真后端 e2e + fixture 复位
handoff-knowledge.spec(真后端 48080, MSW off):
- 正路:市场资产→获取授权→bind-precheck 就绪→前往绑定(createHandoff token)→落地页(URL token 经 replaceState
  清除)→确认→kbBindPrecheck(后端核验+核销 token)→createBinding→绑定完成。证红线兑现闭环真生效。
- 负路:伪造 handoffToken 直打 kbBindPrecheck→后端 verify 拒(code 非 0)。证"不信客户端 token"红线后端真拦截。
- 用 work4(creator=test1、未占 uk);work1+kb1 已被 installed binding 占,避 uk_muse_knowledge_binding_work_kb 冲突。

global-setup #12:每轮清理 handoff 兑现产生的 market_kb 绑定(work4+kb1,按 source_snapshot_id 精准删),供闭环幂等重跑。

验证:handoff-knowledge 2 passed + 二次幂等绿;全量 48 passed。
(knowledge-disable-restore 全量时序 flaky——单跑 2 passed,预存在共享 fixture 时序、与本改动无关:handoff 不碰 KB id=1。)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 10:31:18 -07:00

95 lines
4.3 KiB
TypeScript

import { expect, test, type Page } from '@playwright/test';
/**
* 跨空间 handoff → knowledge 兑现端到端 e2e(真实后端,MSW off,反假绿)。
*
* 前置:vite VITE_API_MOCK=false、真实 muse-server 48080(含 P1 verify/consume)、muse_slice_live asset 1(market_kb)+ work 1。
*
* 正路(UI 端到端):资产详情→获取授权→bind-precheck 就绪→前往绑定(createHandoff 一次性 token)→落地页(URL token
* 被 replaceState 清除)→确认→kbBindPrecheck(后端核验+核销 token)→createBinding→绑定完成。证红线兑现闭环真生效。
* 负路(API 直打):伪造 handoffToken 调 kbBindPrecheck→后端 verify 拒(code 非 0)。证"不信客户端 token"红线在后端真拦截。
*/
const TOKEN = 'test1';
const API = 'http://localhost:48080/app-api/muse';
const AUTH = { Authorization: 'Bearer test1', 'tenant-id': '1', 'X-API-Version': '1' };
async function seedToken(page: Page): Promise<void> {
await page.addInitScript((t) => {
window.localStorage.setItem('accessToken', t);
window.localStorage.setItem('tenantId', '1');
}, TOKEN);
}
test('正路:市场资产 handoff 兑现到 knowledge 闭环(真实后端)', async ({ page, request }) => {
await seedToken(page);
// 前提:asset 1 已授权(幂等 best-effort;bind-precheck 需授权存在)
await request
.post(`${API}/marketplace/assets/1/purchase`, { headers: AUTH, data: { commandId: `e2e-acq-${Date.now()}` } })
.catch(() => undefined);
await page.goto('/market/assets/1');
// 1. 来源侧授权预检 → 就绪(用 work4:creator=test1、未占 uk(work,kb);work1+kb1 已被 installed binding 占)
await page.getByLabel('目标作品 ID').fill('4');
await page.getByRole('button', { name: '授权预检' }).click();
await expect(page.getByText('可绑定(就绪)')).toBeVisible({ timeout: 15_000 });
// 2. 前往绑定:createHandoff 创建一次性 token + 跳落地页
const [handoffResp] = await Promise.all([
page.waitForResponse(
(r) => /\/marketplace\/handoffs$/.test(r.url()) && r.request().method() === 'POST',
{ timeout: 15_000 }
),
page.getByRole('button', { name: /前往绑定/ }).click(),
]);
expect(handoffResp.status()).toBe(200);
const handoffBody = await handoffResp.json();
expect(handoffBody.code).toBe(0);
expect(handoffBody.data?.handoffToken).toBeTruthy();
// 3. 落地页:URL token 已被 replaceState 清除(红线:不残留可重放凭据)
await expect(page).toHaveURL(/\/handoff\/land\/knowledge/, { timeout: 15_000 });
expect(page.url()).not.toContain('token=');
await expect(page.getByText('确认绑定市场知识来源')).toBeVisible({ timeout: 15_000 });
// 4. 确认绑定 → kbBindPrecheck(后端核验 + 核销 token)
await page.getByRole('button', { name: /确认绑定/ }).click();
const kbResp = await page.waitForResponse(
(r) => /\/knowledge-bindings\/prechecks$/.test(r.url()) && r.request().method() === 'POST',
{ timeout: 20_000 }
);
expect(kbResp.status()).toBe(200);
expect((await kbResp.json()).code, 'kbBindPrecheck code').toBe(0);
// 5. createBinding(只消费 precheckId,落 knowledge_binding)
const bindResp = await page.waitForResponse(
(r) => /\/knowledge-bindings$/.test(r.url()) && r.request().method() === 'POST',
{ timeout: 20_000 }
);
const bindBody = await bindResp.json();
expect(bindBody.code, `createBinding 返回: ${JSON.stringify(bindBody)}`).toBe(0);
// 6. 兑现完成
await expect(page.getByText('绑定完成')).toBeVisible({ timeout: 15_000 });
});
test('负路:伪造 handoffToken 调 kbBindPrecheck 被后端核验拒绝(真实后端 API)', async ({ request }) => {
// 直打后端:伪造 token(从未由 Market 发起),后端 verify 应拒,不落任何绑定。
const resp = await request.post(`${API}/works/1/knowledge-bindings/prechecks`, {
headers: AUTH,
data: {
commandId: `e2e-forge-${Date.now()}`,
sourceType: 'market_kb',
sourceId: '1',
sourceVersion: 1,
sourceStatus: 'available',
handoffToken: 'handoff_forged_invalid_token_000000000000',
authorizationSummaryId: '9001',
authorizationSnapshotId: 'snapshot-forged',
},
});
// 后端核验拒绝:HTTP 4xx 或 CommonResult.code 非 0(KNOWLEDGE_MARKET_HANDOFF_UNAVAILABLE)。
const body = await resp.json().catch(() => ({ code: -1 }));
expect(body.code).not.toBe(0);
});