完成 2.0.0 S4 的外部 AI、admin、account 与 solo SQL 收口,并推进 S5 studio 市场/handoff/旧编辑器入口裁剪。S5 的前端单测、构建与 route-only e2e 已留证;MSW-off 创作主线 e2e 仍因本地后端活体启动口径待修正,已在总账标为未完成验收。
137 lines
5.7 KiB
TypeScript
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);
|
|
});
|