mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-01 21:26:08 +08:00
- 内置工具与资源改为 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 更新日志的破坏性变更
153 lines
5.1 KiB
TypeScript
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;
|
|
});
|