oh-my-muse/muse-studio/e2e/handoff-knowledge.spec.ts
zizi 6dcabf69fb feat(solo): 收口治理入口并裁剪 studio 表面
完成 2.0.0 S4 的外部 AI、admin、account 与 solo SQL 收口,并推进 S5 studio 市场/handoff/旧编辑器入口裁剪。S5 的前端单测、构建与 route-only e2e 已留证;MSW-off 创作主线 e2e 仍因本地后端活体启动口径待修正,已在总账标为未完成验收。
2026-07-08 11:45:26 +08:00

137 lines
5.7 KiB
TypeScript

import { expect, test, type Page } from '@playwright/test';
import { Client } from 'pg';
test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复');
/**
* 跨空间 handoff → knowledge 兑现端到端 e2e(真实后端,MSW off,反假绿)。
*
* 前置:vite VITE_API_MOCK=false、真实 muse-server 48080(含 verify/consume)、muse_slice_live marker knowledge_base 资产 + work 4。
*
* 正路(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' };
const KNOWLEDGE_HANDOFF_ASSET_NAME = '活体市场知识库·handoff e2e';
async function seedToken(page: Page): Promise<void> {
await page.addInitScript((t) => {
window.localStorage.setItem('accessToken', t);
window.localStorage.setItem('tenantId', '1');
}, TOKEN);
}
function createPgClient(): Client {
return new Client({
host: process.env.MUSE_POSTGRES_HOST,
port: Number(process.env.MUSE_POSTGRES_PORT ?? '5433'),
database: process.env.MUSE_POSTGRES_DATABASE ?? 'muse_slice_live',
user: process.env.MUSE_POSTGRES_USERNAME ?? 'root',
password: process.env.MUSE_POSTGRES_PASSWORD,
ssl: false,
connectionTimeoutMillis: 10_000,
});
}
async function readKnowledgeHandoffAssetId(): Promise<number> {
const client = createPgClient();
await client.connect();
try {
const result = await client.query(
`SELECT id
FROM muse_market_asset
WHERE tenant_id=1
AND name=$1
AND asset_type='knowledge_base'
AND listing_status='listed'
AND status='active'
AND deleted=false
ORDER BY id DESC
LIMIT 1`,
[KNOWLEDGE_HANDOFF_ASSET_NAME]
);
expect(result.rowCount, '应存在 knowledge_base handoff e2e 市场资产').toBe(1);
return Number(result.rows[0].id);
} finally {
await client.end();
}
}
test('正路:市场资产 handoff 兑现到 knowledge 闭环(真实后端)', async ({ page, request }) => {
const assetId = await readKnowledgeHandoffAssetId();
await seedToken(page);
// 前提:knowledge_base 市场资产已授权(幂等 best-effort;bind-precheck 需授权存在)。
await request
.post(`${API}/marketplace/assets/${assetId}/purchase`, { headers: AUTH, data: { commandId: `e2e-acq-${Date.now()}` } })
.catch(() => undefined);
await page.goto(`/market/assets/${assetId}`);
// 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 }) => {
const assetId = await readKnowledgeHandoffAssetId();
// 直打后端:伪造 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: String(assetId),
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);
});