Files
Auto_Bangumi/webui/e2e/specs/session-access.spec.ts
Estrella Pan 73fbca985b refactor(mcp): 升级 mcp 2.3,改用 MCPServer 与 Streamable HTTP(端点 /mcp)
- 内置工具与资源改为 AutoBangumiMCP(MCPServer 子类)上的 @mcp.tool() /
  @mcp.resource(),参数 schema 由类型注解生成,工具名、描述与参数名不变;
  结果仍是一段 JSON 文本,异常返回 is_error 结果
- 插件工具与资源在子类的 list / call / read 中追加与分发,熔断与超时逻辑
  不变;mcp 2 不再校验参数,插件工具按 input_schema 用 jsonschema 校验,
  不合法的参数不计入熔断
- 传输只保留 Streamable HTTP,以路由挂载在 /mcp(mount 会把 /mcp 重定向到
  /mcp/),删除 /mcp/sse 与 /mcp/messages/;会话管理器在应用 lifespan 中运行;
  关闭 DNS rebinding 防护,访问控制仍由 IP 白名单与令牌负责
- 更新 MCP 文档、e2e 用例与 4.0 更新日志的破坏性变更
2026-10-20 11:40:32 +02:00

153 lines
5.1 KiB
TypeScript

import { request as playwrightRequest } from '@playwright/test';
import { E2EApi } from '../fixtures/api';
import type { AuthenticatedContext } from '../fixtures/auth';
import {
closeAuthenticatedContexts,
loginThroughUI,
newAuthenticatedContext,
} from '../fixtures/auth';
import type { ApiTokenCreated } from '../fixtures/data';
import { SESSION_SUCCESS, changedPassword } from '../fixtures/data';
import { expect, test } from '../fixtures/test';
test('password change rotates both browser contexts and rejects the old password', async ({
api,
browser,
environment,
uniqueName,
}, testInfo) => {
const credentials = {
username: `u-${uniqueName.slice(-16)}`,
password: 'Initial-Password9!',
};
const user = await api.createUser(credentials);
let first: AuthenticatedContext | undefined;
let second: AuthenticatedContext | undefined;
let cleanupFailure: unknown;
try {
first = await newAuthenticatedContext(
browser,
environment,
credentials,
testInfo
);
second = await newAuthenticatedContext(
browser,
environment,
credentials,
testInfo
);
await first.page.getByRole('button', { name: 'System menu' }).click();
await first.page.getByRole('menuitem', { name: 'Profile' }).click();
const dialog = first.page.getByRole('dialog').last();
const nextCredentials = {
username: credentials.username,
password: changedPassword(uniqueName),
};
await dialog.getByLabel('Username').fill(nextCredentials.username);
await dialog
.getByLabel('Password', { exact: true })
.fill(nextCredentials.password);
const updateResponse = first.page.waitForResponse(
(response) =>
response.url().endsWith('/api/v1/auth/update') &&
response.request().method() === 'POST'
);
await dialog.getByRole('button', { name: 'Update' }).click();
expect(await (await updateResponse).json()).toEqual(SESSION_SUCCESS);
await new E2EApi(second.context.request).me(401);
await second.context.clearCookies();
await second.page.evaluate(() =>
localStorage.setItem('isLoggedIn', 'false')
);
await second.page.reload();
await expect(second.page).toHaveURL(/#\/login$/);
await loginThroughUI(second.page, credentials, 401);
await loginThroughUI(second.page, nextCredentials);
await expect(second.page).toHaveURL(/#\/bangumi$/);
} finally {
const contexts = [first, second].filter(
(value): value is AuthenticatedContext => value !== undefined
);
const cleanup = await Promise.allSettled([
closeAuthenticatedContexts(contexts, testInfo),
api.deleteUser(user.id),
]);
const failure = cleanup.find(
(result): result is PromiseRejectedResult => result.status === 'rejected'
);
cleanupFailure = failure?.reason;
}
if (cleanupFailure) throw cleanupFailure;
});
test('API and MCP tokens are isolated and Bearer cannot control accounts', async ({
api,
environment,
uniqueName,
}) => {
const bearerContext = await playwrightRequest.newContext({
baseURL: environment.baseURL,
});
const bearerApi = new E2EApi(bearerContext);
const tokens: ApiTokenCreated[] = [];
let cleanupFailure: unknown;
try {
const apiToken = await api.createToken(`${uniqueName}-api`, 'api');
tokens.push(apiToken);
const mcpToken = await api.createToken(`${uniqueName}-mcp`, 'mcp');
tokens.push(mcpToken);
await bearerApi.bearerGet('/api/v1/bangumi/get/all', apiToken.token, 200);
await bearerApi.bearerGet('/api/v1/users', apiToken.token, 403);
await bearerApi.bearerGet('/api/v1/tokens', apiToken.token, 403);
await bearerApi.bearerGet('/api/v1/bangumi/get/all', mcpToken.token, 401);
await bearerApi.bearerGet('/mcp', apiToken.token, 403);
// An explicit invalid Authorization header must beat the valid admin cookie.
await api.bearerGet('/api/v1/bangumi/get/all', mcpToken.token, 401);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(`${environment.baseURL}/mcp`, {
method: 'POST',
headers: {
Authorization: `Bearer ${mcpToken.token}`,
Accept: 'application/json, text/event-stream',
'Content-Type': 'application/json',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'e2e', version: '0' },
},
}),
signal: controller.signal,
});
expect(response.status).toBe(200);
} finally {
clearTimeout(timeout);
controller.abort();
}
} finally {
const cleanup = await Promise.allSettled([
bearerContext.dispose(),
...tokens.map((token) => api.revokeToken(token.id)),
]);
const failure = cleanup.find(
(result): result is PromiseRejectedResult => result.status === 'rejected'
);
cleanupFailure = failure?.reason;
}
if (cleanupFailure) throw cleanupFailure;
});