mirror of
https://github.com/insistence/RuoYi-Vue3-FastAPI.git
synced 2026-10-14 21:07:36 +00:00
* feat: 支持 v2 插件、原生扩展与签名发布 * fix: 修复测试 * feat: 增强宿主 SDK、运行观测与制品维护能力 * feat: 增强文件传输、配置观测与本地开发能力 * feat: 支持 SSE 实时事件流并完善开发脚手架 * feat: 完善长连接鉴权复核与关闭回收 * feat: 增强生命周期与任务关闭可靠性 * feat: 收紧健康检查契约并补齐前端与真实网络验收 * feat: 增加SDK溯源与离线更新工具并验证跨版本兼容 * feat: 增加持久化业务示例与受控 Python 交付 * feat: 增加统一运行诊断与多worker状态观测 * feat: 增加固定目标发布等待与自动验收 * fix: 修复多环境插件部署前缀与代理配置不一致 * fix: 修复测试
951 lines
29 KiB
JavaScript
951 lines
29 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import {
|
|
createPluginClient,
|
|
createPluginHostBridge,
|
|
normalizePluginBase,
|
|
PLUGIN_BRIDGE_MAX_BYTES,
|
|
PLUGIN_BRIDGE_MAX_FILE_BYTES,
|
|
validatePluginApiPath,
|
|
validatePluginRoute,
|
|
validatePluginSession,
|
|
} from '../../src/utils/pluginBridge.js'
|
|
import { normalizeTransportPath } from '../../src/utils/transportPath.js'
|
|
|
|
// 文件通过真实结构化克隆进入宿主,表单由宿主重建且仍使用受限会话。
|
|
async function checkFileTransfers() {
|
|
const requests = []
|
|
const events = []
|
|
const pair = await setup({
|
|
request: async (config) => {
|
|
requests.push(config)
|
|
if (config.responseType === 'blob') {
|
|
config.onDownloadProgress({ loaded: 6, total: 6 })
|
|
return new Blob(['报告'], { type: 'text/plain' })
|
|
}
|
|
assert.equal(config.data.get('category'), 'test')
|
|
const file = config.data.get('attachment')
|
|
assert.equal(file.name, '报告.txt')
|
|
assert.equal(await file.text(), '报告')
|
|
config.onUploadProgress({ loaded: 3, total: 6 })
|
|
config.onUploadProgress({ loaded: 6, total: 6 })
|
|
return { received: file.size }
|
|
},
|
|
})
|
|
try {
|
|
const result = await pair.child.upload(
|
|
{
|
|
path: 'files/upload',
|
|
file: new Blob(['报告']),
|
|
filename: '报告.txt',
|
|
fieldName: 'attachment',
|
|
fields: { category: 'test' },
|
|
},
|
|
{
|
|
onProgress: (event) => {
|
|
events.push(event)
|
|
throw new Error('插件回调错误')
|
|
},
|
|
}
|
|
)
|
|
assert.deepEqual(result, { received: 6 })
|
|
assert.deepEqual(events.at(-1), { phase: 'upload', loaded: 6, total: 6 })
|
|
const blob = await pair.child.download(
|
|
{ path: 'files/report', params: { month: '10' } },
|
|
{ onProgress: (event) => events.push(event) }
|
|
)
|
|
assert.equal(await blob.text(), '报告')
|
|
assert.equal(blob.type, 'text/plain')
|
|
assert.deepEqual(events.at(-1), { phase: 'download', loaded: 6, total: 6 })
|
|
for (const config of requests) {
|
|
assert.equal(config.headers.isToken, false)
|
|
assert.equal(config.headers['X-Plugin-CSRF'], sessionData.csrfToken)
|
|
assert.equal(config.headers.Authorization, undefined)
|
|
assert.equal(config.headers.encrypt, false)
|
|
assert.equal(config.timeout, 120000)
|
|
assert.equal(config.pluginBridge, true)
|
|
}
|
|
assert.equal(requests[0].headers['Content-Type'], undefined)
|
|
assert.equal(requests[0].url, '/apps/demo/api/files/upload')
|
|
assert.equal(pair.child.context.capabilities.files.maxBytes, PLUGIN_BRIDGE_MAX_FILE_BYTES)
|
|
} finally {
|
|
pair.destroy()
|
|
}
|
|
}
|
|
|
|
const ORIGIN = 'https://example.test'
|
|
const sessionData = {
|
|
pluginId: 'demo',
|
|
bridgeVersion: 1,
|
|
uiBase: '/apps/demo/ui/',
|
|
apiBase: '/apps/demo/api/',
|
|
csrfToken: 'csrf-token-for-tests-only',
|
|
expiresIn: 300,
|
|
}
|
|
const deferred = () => {
|
|
let resolve
|
|
let reject
|
|
const promise = new Promise((a, b) => {
|
|
resolve = a
|
|
reject = b
|
|
})
|
|
return { promise, resolve, reject }
|
|
}
|
|
const flush = () => new Promise((resolve) => setImmediate(resolve))
|
|
|
|
function windowPair() {
|
|
const makeWindow = () => {
|
|
const listeners = new Set()
|
|
return {
|
|
messages: [],
|
|
listeners,
|
|
addEventListener(type, listener) {
|
|
if (type === 'message') listeners.add(listener)
|
|
},
|
|
removeEventListener(type, listener) {
|
|
if (type === 'message') listeners.delete(listener)
|
|
},
|
|
dispatch(event) {
|
|
for (const listener of [...listeners]) listener(event)
|
|
},
|
|
}
|
|
}
|
|
const hostWindow = makeWindow()
|
|
const childWindow = makeWindow()
|
|
for (const [target, source] of [
|
|
[hostWindow, childWindow],
|
|
[childWindow, hostWindow],
|
|
]) {
|
|
target.postMessage = (data, targetOrigin) => {
|
|
assert.equal(targetOrigin, ORIGIN, 'all messages must name an exact origin')
|
|
data = structuredClone(data)
|
|
target.messages.push(data)
|
|
queueMicrotask(() => target.dispatch({ data, source, origin: ORIGIN }))
|
|
}
|
|
}
|
|
return { hostWindow, childWindow }
|
|
}
|
|
|
|
async function setup({
|
|
request = async () => ({ code: 200, data: 'ok' }),
|
|
now = Date.now,
|
|
timeoutMs = 15000,
|
|
transferTimeoutMs = 120000,
|
|
stream,
|
|
streamTimeoutMs = 300000,
|
|
} = {}) {
|
|
const { hostWindow, childWindow } = windowPair()
|
|
let target = childWindow
|
|
let theme = 'light'
|
|
const routes = []
|
|
const host = createPluginHostBridge({
|
|
pluginId: 'demo',
|
|
session: validatePluginSession(sessionData, 'demo', '', now()),
|
|
getTarget: () => target,
|
|
request,
|
|
stream,
|
|
getContext: () => ({
|
|
theme: { mode: theme },
|
|
language: 'zh-CN',
|
|
timeZone: 'Asia/Shanghai',
|
|
route: '/',
|
|
}),
|
|
onRoute: (route) => routes.push(route),
|
|
eventTarget: hostWindow,
|
|
origin: ORIGIN,
|
|
now,
|
|
timeoutMs,
|
|
transferTimeoutMs,
|
|
streamTimeoutMs,
|
|
})
|
|
const child = createPluginClient({
|
|
pluginId: 'demo',
|
|
eventTarget: childWindow,
|
|
parentWindow: hostWindow,
|
|
origin: ORIGIN,
|
|
transferTimeoutMs,
|
|
streamTimeoutMs,
|
|
})
|
|
await child.ready
|
|
return {
|
|
hostWindow,
|
|
childWindow,
|
|
host,
|
|
child,
|
|
routes,
|
|
replaceTarget: () => {
|
|
target = {}
|
|
},
|
|
changeTheme: () => {
|
|
theme = 'dark'
|
|
host.updatePreferences()
|
|
},
|
|
destroy: () => {
|
|
host.destroy()
|
|
child.destroy()
|
|
},
|
|
}
|
|
}
|
|
|
|
assert.equal(normalizePluginBase('/deployment/'), '/deployment')
|
|
for (const base of ['//evil.test', 'https://evil.test', '/a/../b', '/%2e', '/x\\y', '/x//y']) {
|
|
assert.throws(() => normalizePluginBase(base))
|
|
}
|
|
assert.equal(validatePluginSession(sessionData, 'demo', '', 100).expiresAt, 300100)
|
|
assert.equal(
|
|
validatePluginSession(
|
|
{ ...sessionData, uiBase: '/prefix/apps/demo/ui/', apiBase: '/prefix/apps/demo/api/' },
|
|
'demo',
|
|
'/prefix'
|
|
).apiBase,
|
|
'/prefix/apps/demo/api/'
|
|
)
|
|
for (const changes of [
|
|
{ uiBase: '//evil.test/apps/demo/ui/' },
|
|
{ uiBase: 'https://example.test/apps/demo/ui/' },
|
|
{ apiBase: '/apps/other/api/' },
|
|
{ apiBase: '/prefix/apps/demo/api/' },
|
|
{ apiBase: '/apps/demo/api/../' },
|
|
{ pluginId: 'other' },
|
|
{ expiresIn: 0 },
|
|
{ expiresIn: 5000 },
|
|
{ bridgeVersion: 2 },
|
|
{ csrfToken: 'bad\nheader' },
|
|
])
|
|
assert.throws(() => validatePluginSession({ ...sessionData, ...changes }, 'demo'))
|
|
for (const path of ['', 'status', 'model/v1.2', 'reports/2026-10/'])
|
|
assert.equal(validatePluginApiPath(path), path)
|
|
for (const path of [
|
|
'/status',
|
|
'//evil.test',
|
|
'https://evil.test',
|
|
'..',
|
|
'a/../b',
|
|
'a/./b',
|
|
'a//b',
|
|
'a\\b',
|
|
'%2e%2e/admin',
|
|
'%252e%252e',
|
|
'a%2fb',
|
|
'a?x=1',
|
|
'a#hash',
|
|
'a\n',
|
|
]) {
|
|
assert.throws(() => validatePluginApiPath(path), path)
|
|
}
|
|
assert.equal(validatePluginRoute('/reports/today'), '/reports/today')
|
|
assert.throws(() => validatePluginRoute('//outside'))
|
|
assert.throws(() => validatePluginRoute('/../admin'))
|
|
assert.equal(
|
|
normalizeTransportPath('/root/apps/demo/api/status', '/dev-api', '/root'),
|
|
'/apps/demo/api/status'
|
|
)
|
|
assert.equal(
|
|
normalizeTransportPath('/root/plugin/runtime/demo/session', '/dev-api', '/root'),
|
|
'/plugin/runtime/demo/session'
|
|
)
|
|
assert.equal(normalizeTransportPath('/dev-api/system/user', '/dev-api'), '/system/user')
|
|
assert.equal(normalizeTransportPath('/dev-api-other/user', '/dev-api'), '/dev-api-other/user')
|
|
assert.equal(
|
|
normalizeTransportPath('/rooted/apps/demo/api/status', '/dev-api', '/root'),
|
|
'/rooted/apps/demo/api/status'
|
|
)
|
|
|
|
// 验证握手、接口请求和上下文同步,不向子页面传递宿主令牌或 CSRF 令牌。
|
|
{
|
|
const requests = []
|
|
const pair = await setup({
|
|
request: async (config) => {
|
|
requests.push(config)
|
|
return { code: 200, data: [1, 2] }
|
|
},
|
|
})
|
|
assert.equal(pair.child.context.apiBase, '/apps/demo/api/')
|
|
assert.equal(pair.child.context.csrfToken, undefined)
|
|
const result = await pair.child.request({
|
|
method: 'POST',
|
|
path: 'status',
|
|
data: { value: 3 },
|
|
params: { page: 1 },
|
|
})
|
|
assert.deepEqual(result, { code: 200, data: [1, 2] })
|
|
assert.equal(requests[0].url, '/apps/demo/api/status')
|
|
assert.equal(requests[0].baseURL, '')
|
|
assert.deepEqual(requests[0].headers, {
|
|
isToken: false,
|
|
'X-Plugin-CSRF': sessionData.csrfToken,
|
|
repeatSubmit: false,
|
|
})
|
|
assert.equal(requests[0].headers.Authorization, undefined)
|
|
assert.equal(requests[0].pluginBridge, true)
|
|
pair.changeTheme()
|
|
await flush()
|
|
assert.equal(pair.child.context.theme.mode, 'dark')
|
|
pair.child.navigate('/details')
|
|
await flush()
|
|
assert.deepEqual(pair.routes, ['/details'])
|
|
pair.host.updateRoute('/back')
|
|
await flush()
|
|
assert.equal(pair.child.context.route, '/back')
|
|
const events = []
|
|
pair.child.subscribe((event) => events.push(event.type))
|
|
pair.host.refresh()
|
|
await flush()
|
|
assert.deepEqual(events, ['refresh'])
|
|
pair.destroy()
|
|
assert.equal(pair.hostWindow.listeners.size, 0)
|
|
assert.equal(pair.childWindow.listeners.size, 0)
|
|
}
|
|
|
|
// 拒绝伪造来源、旧页面随机标识、无效字段和接口路径越界。
|
|
{
|
|
let calls = 0
|
|
const pair = await setup({
|
|
request: async () => {
|
|
calls += 1
|
|
return {}
|
|
},
|
|
})
|
|
const initialization = pair.childWindow.messages.find((message) => message.type === 'initialize')
|
|
const message = {
|
|
namespace: 'ruoyi.plugin',
|
|
version: 1,
|
|
pluginId: 'demo',
|
|
type: 'request',
|
|
instance: initialization.instance,
|
|
id: 'test',
|
|
payload: { method: 'GET', path: 'status' },
|
|
}
|
|
const emit = (data, origin = ORIGIN, source = pair.childWindow) =>
|
|
pair.hostWindow.dispatch({ data, origin, source })
|
|
emit(message, 'https://evil.test')
|
|
emit(message, ORIGIN, {})
|
|
emit({ ...message, instance: 'previous-frame' })
|
|
emit({ ...message, version: 2 })
|
|
emit({ ...message, pluginId: 'other' })
|
|
emit({ ...message, headers: { Authorization: 'bad' } })
|
|
await flush()
|
|
assert.equal(calls, 0)
|
|
for (const payload of [
|
|
{ method: 'GET', path: '/admin' },
|
|
{ method: 'GET', path: 'status', headers: { Authorization: 'bad' } },
|
|
{ method: 'GET', path: 'status', baseURL: 'https://evil.test' },
|
|
{ method: 'GET', path: 'status', data: {} },
|
|
{ method: 'TRACE', path: 'status' },
|
|
{ method: 'POST', path: 'status', data: 'x'.repeat(PLUGIN_BRIDGE_MAX_BYTES + 1) },
|
|
{ method: 'POST', path: 'status', data: JSON.parse('{"__proto__":{"bad":true}}') },
|
|
]) {
|
|
emit({ ...message, payload })
|
|
await flush()
|
|
assert.equal(pair.childWindow.messages.at(-1).payload.ok, false)
|
|
}
|
|
assert.equal(calls, 0)
|
|
pair.replaceTarget()
|
|
emit(message)
|
|
await flush()
|
|
assert.equal(calls, 0)
|
|
pair.destroy()
|
|
}
|
|
|
|
// 页面超时、销毁或会话过期后,不再返回旧请求的结果。
|
|
{
|
|
let now = 0
|
|
const pending = deferred()
|
|
let captured
|
|
const pair = await setup({
|
|
now: () => now,
|
|
request: (config) => {
|
|
captured = config
|
|
return pending.promise
|
|
},
|
|
})
|
|
const promise = pair.child.request({ method: 'GET', path: 'slow' })
|
|
const rejected = assert.rejects(promise, /失效/)
|
|
await flush()
|
|
pair.host.destroy({ logout: true })
|
|
await rejected
|
|
assert.equal(captured.signal.aborted, true)
|
|
pending.resolve({ secret: 'late-result' })
|
|
await flush()
|
|
assert.equal(
|
|
pair.childWindow.messages.some((message) => message.type === 'result'),
|
|
false
|
|
)
|
|
assert.equal(pair.childWindow.listeners.size, 0)
|
|
pair.destroy()
|
|
}
|
|
{
|
|
const pending = deferred()
|
|
let captured
|
|
const pair = await setup({
|
|
request: (config) => {
|
|
captured = config
|
|
return pending.promise
|
|
},
|
|
timeoutMs: 10,
|
|
})
|
|
await assert.rejects(pair.child.request({ method: 'GET', path: 'slow' }), /超时/)
|
|
assert.equal(captured.signal.aborted, true)
|
|
pending.resolve({ late: true })
|
|
await flush()
|
|
assert.equal(pair.childWindow.messages.filter((message) => message.type === 'result').length, 1)
|
|
pair.destroy()
|
|
}
|
|
{
|
|
let now = 0
|
|
let calls = 0
|
|
const pair = await setup({
|
|
now: () => now,
|
|
request: async () => {
|
|
calls += 1
|
|
return {}
|
|
},
|
|
})
|
|
now = 301000
|
|
const controller = new AbortController()
|
|
const promise = pair.child.request(
|
|
{ method: 'GET', path: 'status' },
|
|
{ signal: controller.signal }
|
|
)
|
|
const rejected = assert.rejects(promise, /取消/)
|
|
await flush()
|
|
assert.equal(calls, 0)
|
|
controller.abort()
|
|
await rejected
|
|
pair.destroy()
|
|
}
|
|
|
|
// 会话续期只更新宿主侧会话和 CSRF 令牌,不重新启动子页面 SDK。
|
|
{
|
|
let captured
|
|
const pair = await setup({
|
|
request: async (config) => {
|
|
captured = config
|
|
return {}
|
|
},
|
|
})
|
|
pair.host.setSession(
|
|
validatePluginSession({ ...sessionData, csrfToken: 'renewed-csrf-token-test' }, 'demo')
|
|
)
|
|
await pair.child.request({ method: 'GET', path: 'status' })
|
|
assert.equal(captured.headers['X-Plugin-CSRF'], 'renewed-csrf-token-test')
|
|
assert.equal(
|
|
pair.childWindow.messages.filter((message) => message.type === 'initialize').length,
|
|
1
|
|
)
|
|
assert.throws(() => pair.host.setSession({ ...sessionData, apiBase: '/apps/other/api/' }))
|
|
pair.destroy()
|
|
}
|
|
|
|
// iframe 重载会保留 WindowProxy,但必须更换文档随机标识并取消旧请求。
|
|
// 旧文档迟到的 ready 消息不能恢复旧连接。
|
|
{
|
|
const pending = deferred()
|
|
const captured = []
|
|
const pair = await setup({
|
|
request: (config) => {
|
|
captured.push(config)
|
|
return config.url.endsWith('/slow') ? pending.promise : Promise.resolve({ code: 200 })
|
|
},
|
|
})
|
|
const oldReady = pair.hostWindow.messages.find((message) => message.type === 'ready')
|
|
const oldInit = pair.childWindow.messages.find((message) => message.type === 'initialize')
|
|
const oldPromise = pair.child.request({ method: 'GET', path: 'slow' })
|
|
const oldRejected = assert.rejects(oldPromise, /关闭/)
|
|
await flush()
|
|
// 在同一个子窗口 WindowProxy 上模拟新文档的 SDK。
|
|
const fresh = createPluginClient({
|
|
pluginId: 'demo',
|
|
eventTarget: pair.childWindow,
|
|
parentWindow: pair.hostWindow,
|
|
origin: ORIGIN,
|
|
})
|
|
await fresh.ready
|
|
assert.equal(captured[0].signal.aborted, true)
|
|
const newInit = pair.childWindow.messages
|
|
.filter((message) => message.type === 'initialize')
|
|
.at(-1)
|
|
assert.notEqual(newInit.instance, oldInit.instance)
|
|
pair.hostWindow.dispatch({ data: oldReady, origin: ORIGIN, source: pair.childWindow })
|
|
pending.resolve({ data: 'from-old-page' })
|
|
await flush()
|
|
assert.equal(
|
|
pair.childWindow.messages.filter((message) => message.type === 'initialize').length,
|
|
2
|
|
)
|
|
assert.equal(
|
|
pair.childWindow.messages.some((message) => message.type === 'result'),
|
|
false
|
|
)
|
|
pair.child.destroy()
|
|
await oldRejected
|
|
assert.deepEqual(await fresh.request({ method: 'GET', path: 'status' }), { code: 200 })
|
|
fresh.destroy()
|
|
pair.destroy()
|
|
}
|
|
|
|
// 验证并发上限和取消行为,服务端异常只返回可展示的错误文本。
|
|
{
|
|
const requests = []
|
|
const pair = await setup({
|
|
request: (config) => {
|
|
requests.push(config)
|
|
return new Promise(() => {})
|
|
},
|
|
})
|
|
const promises = Array.from({ length: 8 }, () =>
|
|
pair.child.request({ method: 'GET', path: 'wait' })
|
|
)
|
|
const rejected = promises.map((promise) => assert.rejects(promise, /关闭/))
|
|
await flush()
|
|
await assert.rejects(pair.child.request({ method: 'GET', path: 'extra' }), /过多/)
|
|
pair.child.destroy()
|
|
await Promise.all(rejected)
|
|
await flush()
|
|
assert.equal(requests.length, 8)
|
|
assert.equal(
|
|
requests.every((config) => config.signal.aborted),
|
|
true
|
|
)
|
|
pair.host.destroy()
|
|
}
|
|
{
|
|
const pair = await setup({
|
|
request: async () => {
|
|
throw new Error('secret master token stack trace')
|
|
},
|
|
})
|
|
await assert.rejects(
|
|
pair.child.request({ method: 'GET', path: 'failure' }),
|
|
/^Error: 插件请求失败,请重试$/
|
|
)
|
|
assert.equal(JSON.stringify(pair.childWindow.messages).includes('secret master'), false)
|
|
pair.destroy()
|
|
}
|
|
|
|
// 宿主无响应时,SDK 应在握手超时后移除监听。
|
|
{
|
|
const pair = windowPair()
|
|
const child = createPluginClient({
|
|
pluginId: 'demo',
|
|
eventTarget: pair.childWindow,
|
|
parentWindow: pair.hostWindow,
|
|
origin: ORIGIN,
|
|
timeoutMs: 10,
|
|
})
|
|
await assert.rejects(child.ready, /初始化超时/)
|
|
assert.equal(pair.childWindow.listeners.size, 0)
|
|
}
|
|
|
|
// 首次握手期间支持取消请求,握手前后的排队请求使用相同并发上限。
|
|
{
|
|
const pair = windowPair()
|
|
const child = createPluginClient({
|
|
pluginId: 'demo',
|
|
eventTarget: pair.childWindow,
|
|
parentWindow: pair.hostWindow,
|
|
origin: ORIGIN,
|
|
})
|
|
const controller = new AbortController()
|
|
const promise = child.request({ method: 'GET', path: 'status' }, { signal: controller.signal })
|
|
const cancelled = assert.rejects(promise, /已取消/)
|
|
controller.abort()
|
|
await cancelled
|
|
assert.equal(
|
|
pair.hostWindow.messages.some((message) => message.type === 'request'),
|
|
false
|
|
)
|
|
|
|
const pending = Array.from({ length: 8 }, () => child.request({ method: 'GET', path: 'status' }))
|
|
const rejected = pending.map((entry) => assert.rejects(entry, /关闭/))
|
|
await assert.rejects(child.request({ method: 'GET', path: 'overflow' }), /过多/)
|
|
child.destroy()
|
|
await Promise.all(rejected)
|
|
assert.equal(pair.childWindow.listeners.size, 0)
|
|
}
|
|
|
|
await checkFileTransfers()
|
|
|
|
// 无效文件在子页面和宿主两侧分别拒绝,下载也不能返回错误信封或过大响应。
|
|
{
|
|
let calls = 0
|
|
const pair = await setup({
|
|
request: async () => {
|
|
calls += 1
|
|
return {}
|
|
},
|
|
})
|
|
const valid = { path: 'upload', file: new Blob(['test']) }
|
|
for (const changes of [
|
|
{ file: 'not a file' },
|
|
{ file: new Blob([new Uint8Array(PLUGIN_BRIDGE_MAX_FILE_BYTES + 1)]) },
|
|
{ filename: '../secret' },
|
|
{ filename: 'bad\nname' },
|
|
{ fieldName: 'a\r\nb' },
|
|
{ fields: { file: 'duplicate' } },
|
|
{ fields: { number: 1 } },
|
|
{ params: null },
|
|
{ path: '//outside.test' },
|
|
{ method: 'GET' },
|
|
{ headers: { Authorization: 'forged' } },
|
|
])
|
|
await assert.rejects(pair.child.upload({ ...valid, ...changes }))
|
|
await assert.rejects(pair.child.download({ path: '../outside' }))
|
|
await assert.rejects(pair.child.download({ path: 'ok' }, { onProgress: true }))
|
|
const init = pair.childWindow.messages.find((message) => message.type === 'initialize')
|
|
pair.hostWindow.dispatch({
|
|
origin: ORIGIN,
|
|
source: pair.childWindow,
|
|
data: {
|
|
namespace: 'ruoyi.plugin',
|
|
version: 1,
|
|
pluginId: 'demo',
|
|
instance: init.instance,
|
|
type: 'upload',
|
|
id: 'forged-file',
|
|
payload: {
|
|
method: 'POST',
|
|
path: 'upload',
|
|
file: 'bad',
|
|
fieldName: 'file',
|
|
filename: 'x',
|
|
fields: {},
|
|
},
|
|
},
|
|
})
|
|
await flush()
|
|
assert.equal(calls, 0)
|
|
assert.equal(
|
|
pair.childWindow.messages.find((message) => message.id === 'forged-file').payload.ok,
|
|
false
|
|
)
|
|
pair.destroy()
|
|
}
|
|
for (const body of [
|
|
new Blob([new Uint8Array(PLUGIN_BRIDGE_MAX_FILE_BYTES + 1)]),
|
|
new Blob(['{"code":401,"msg":"secret token"}'], { type: 'application/json' }),
|
|
{ headers: { authorization: 'secret' } },
|
|
]) {
|
|
const pair = await setup({ request: async () => body })
|
|
await assert.rejects(pair.child.download({ path: 'report' }), /插件请求失败/)
|
|
assert.equal(JSON.stringify(pair.childWindow.messages).includes('secret'), false)
|
|
pair.destroy()
|
|
}
|
|
{
|
|
const pair = await setup({
|
|
request: async () => new Blob(['{"report":true}'], { type: 'application/json' }),
|
|
})
|
|
assert.equal(await (await pair.child.download({ path: 'report' })).text(), '{"report":true}')
|
|
pair.destroy()
|
|
}
|
|
|
|
// 超限进度立即取消传输;取消和超时之后,迟到进度及响应均被丢弃。
|
|
for (const mode of ['oversize', 'cancel', 'timeout', 'reload']) {
|
|
const delayed = deferred()
|
|
let config
|
|
const events = []
|
|
const pair = await setup({
|
|
transferTimeoutMs: mode === 'timeout' ? 10 : 120000,
|
|
request: (value) => {
|
|
config = value
|
|
return delayed.promise
|
|
},
|
|
})
|
|
const controller = new AbortController()
|
|
const promise = pair.child.download(
|
|
{ path: 'report' },
|
|
{ signal: controller.signal, onProgress: (event) => events.push(event) }
|
|
)
|
|
const rejected = assert.rejects(promise)
|
|
await flush()
|
|
if (mode === 'oversize') config.onDownloadProgress({ loaded: PLUGIN_BRIDGE_MAX_FILE_BYTES + 1 })
|
|
if (mode === 'cancel') controller.abort()
|
|
if (mode === 'reload') {
|
|
pair.hostWindow.dispatch({
|
|
origin: ORIGIN,
|
|
source: pair.childWindow,
|
|
data: {
|
|
namespace: 'ruoyi.plugin',
|
|
version: 1,
|
|
pluginId: 'demo',
|
|
instance: '',
|
|
type: 'ready',
|
|
payload: { clientId: 'new-file-document' },
|
|
},
|
|
})
|
|
pair.child.destroy()
|
|
}
|
|
await rejected
|
|
await flush()
|
|
assert.equal(config.signal.aborted, true)
|
|
config.onDownloadProgress({ loaded: 3, total: 3 })
|
|
delayed.resolve(new Blob(['old']))
|
|
await flush()
|
|
assert.equal(events.length, 0)
|
|
pair.destroy()
|
|
}
|
|
|
|
// 未公布文件能力的旧宿主立即报兼容错误;握手前调用仍可正常排队。
|
|
for (const filesSupported of [false, true]) {
|
|
const pair = windowPair()
|
|
const child = createPluginClient({
|
|
pluginId: 'demo',
|
|
eventTarget: pair.childWindow,
|
|
parentWindow: pair.hostWindow,
|
|
origin: ORIGIN,
|
|
})
|
|
const pending = child.download({ path: 'report' })
|
|
const clientId = pair.hostWindow.messages[0].payload.clientId
|
|
pair.childWindow.dispatch({
|
|
origin: ORIGIN,
|
|
source: pair.hostWindow,
|
|
data: {
|
|
namespace: 'ruoyi.plugin',
|
|
version: 1,
|
|
pluginId: 'demo',
|
|
type: 'initialize',
|
|
instance: 'host-instance',
|
|
payload: { clientId, ...(filesSupported ? { capabilities: { files: { version: 1 } } } : {}) },
|
|
},
|
|
})
|
|
if (filesSupported) {
|
|
await flush()
|
|
const request = pair.hostWindow.messages.find((message) => message.type === 'download')
|
|
assert.ok(request)
|
|
const rejected = assert.rejects(pending, /关闭/)
|
|
child.destroy()
|
|
await rejected
|
|
} else {
|
|
await assert.rejects(pending, /不支持文件传输/)
|
|
child.destroy()
|
|
}
|
|
}
|
|
|
|
// 实时事件按回调 Promise 确认逐条传递,查询、游标与会话信息只交给宿主适配器。
|
|
{
|
|
const gate = deferred()
|
|
const events = []
|
|
let produced = 0
|
|
let config
|
|
const pair = await setup({
|
|
stream: async function* (value) {
|
|
config = value
|
|
for (let i = 1; i <= 3; i++) {
|
|
produced++
|
|
yield { event: 'tick', data: String(i), id: String(i) }
|
|
}
|
|
},
|
|
})
|
|
const result = pair.child.stream(
|
|
{ path: 'events', params: { count: 3 }, lastEventId: '0' },
|
|
{
|
|
onEvent: async (event) => {
|
|
events.push(event)
|
|
if (event.id === '1') await gate.promise
|
|
},
|
|
}
|
|
)
|
|
await flush()
|
|
assert.equal(produced, 1)
|
|
assert.equal(events.length, 1)
|
|
assert.equal(config.url, '/apps/demo/api/events')
|
|
assert.deepEqual(config.params, { count: 3 })
|
|
assert.equal(config.lastEventId, '0')
|
|
assert.equal(config.csrfToken, sessionData.csrfToken)
|
|
assert.equal(pair.child.context.capabilities.streams.maxConcurrent, 2)
|
|
const streamMessage = pair.hostWindow.messages.find((message) => message.type === 'stream')
|
|
for (const payload of [{ sequence: 2 }, { sequence: 1, extra: true }]) {
|
|
pair.hostWindow.dispatch({
|
|
origin: ORIGIN,
|
|
source: pair.childWindow,
|
|
data: { ...streamMessage, type: 'stream-ack', payload },
|
|
})
|
|
}
|
|
await flush()
|
|
assert.equal(produced, 1, '无效确认不能提前读取下一条事件')
|
|
gate.resolve()
|
|
assert.equal(await result, undefined)
|
|
await flush()
|
|
assert.deepEqual(
|
|
events.map((event) => event.id),
|
|
['1', '2', '3']
|
|
)
|
|
assert.equal(config.signal.aborted, true)
|
|
pair.destroy()
|
|
}
|
|
|
|
// 绕过 SDK 直接发送消息仍受宿主路径、字段和连接数校验。
|
|
{
|
|
let opened = 0
|
|
const pair = await setup({
|
|
stream: async function* ({ signal }) {
|
|
opened++
|
|
await new Promise((resolve) => signal.addEventListener('abort', resolve, { once: true }))
|
|
},
|
|
})
|
|
const initialize = pair.childWindow.messages.find((message) => message.type === 'initialize')
|
|
const sendStream = (id, payload) =>
|
|
pair.hostWindow.dispatch({
|
|
origin: ORIGIN,
|
|
source: pair.childWindow,
|
|
data: { ...initialize, type: 'stream', id, payload },
|
|
})
|
|
sendStream('malformed-stream', { path: 'events', headers: { Authorization: 'forbidden' } })
|
|
await flush()
|
|
assert.equal(opened, 0)
|
|
assert.equal(pair.childWindow.messages.at(-1).payload.ok, false)
|
|
for (const id of ['stream-one', 'stream-two', 'stream-three']) sendStream(id, { path: 'events' })
|
|
await flush()
|
|
assert.equal(opened, 2)
|
|
assert.match(
|
|
pair.childWindow.messages.find((message) => message.id === 'stream-three').payload.message,
|
|
/连接过多/
|
|
)
|
|
pair.destroy()
|
|
}
|
|
|
|
// 初始化尚未完成时也计入实时连接数;取消排队请求后可重新排队。
|
|
{
|
|
const pair = windowPair()
|
|
const child = createPluginClient({
|
|
pluginId: 'demo',
|
|
eventTarget: pair.childWindow,
|
|
parentWindow: pair.hostWindow,
|
|
origin: ORIGIN,
|
|
})
|
|
const controller = new AbortController()
|
|
const first = child.stream({ path: 'events' }, { onEvent() {}, signal: controller.signal })
|
|
const second = child.stream({ path: 'events' }, { onEvent() {} })
|
|
const firstRejected = assert.rejects(first, /取消/)
|
|
const secondRejected = assert.rejects(second, /关闭/)
|
|
await assert.rejects(child.stream({ path: 'events' }, { onEvent() {} }), /连接过多/)
|
|
controller.abort()
|
|
await firstRejected
|
|
const replacement = assert.rejects(child.stream({ path: 'events' }, { onEvent() {} }), /关闭/)
|
|
child.destroy()
|
|
await Promise.all([secondRejected, replacement])
|
|
}
|
|
|
|
// 宿主主动关闭使用固定提示,不泄露鉴权异常,也不交付保留的控制消息。
|
|
{
|
|
const pair = await setup({
|
|
stream: async function* () {
|
|
yield { id: '1', event: 'tick', data: 'first' }
|
|
const error = new Error('secret authentication details')
|
|
error.name = 'PluginStreamClosedError'
|
|
throw error
|
|
},
|
|
})
|
|
const events = []
|
|
await assert.rejects(
|
|
pair.child.stream({ path: 'events' }, { onEvent: (event) => events.push(event) }),
|
|
/宿主关闭/
|
|
)
|
|
assert.equal(events.length, 1)
|
|
assert.equal(JSON.stringify(pair.childWindow.messages).includes('secret'), false)
|
|
pair.destroy()
|
|
}
|
|
|
|
// 无能力协商的旧宿主不接收流请求;接口路径、任意请求头与游标注入均被拒绝。
|
|
{
|
|
const pair = await setup()
|
|
await assert.rejects(pair.child.stream({ path: 'events' }, { onEvent() {} }), /不支持实时/)
|
|
for (const payload of [
|
|
{ path: '../other' },
|
|
{ path: 'events', headers: {} },
|
|
{ path: 'events', method: 'POST' },
|
|
{ path: 'events', lastEventId: 'bad\nvalue' },
|
|
])
|
|
await assert.rejects(pair.child.stream(payload, { onEvent() {} }))
|
|
await assert.rejects(pair.child.stream({ path: 'events' }), /回调/)
|
|
pair.destroy()
|
|
}
|
|
|
|
// 第三个实时连接被拒绝,普通 JSON 请求仍可进行;取消会关闭底层迭代器。
|
|
{
|
|
const controllers = [new AbortController(), new AbortController()]
|
|
let closed = 0
|
|
const pair = await setup({
|
|
stream: async function* ({ signal }) {
|
|
try {
|
|
yield { event: 'tick', data: 'one', id: '1' }
|
|
await new Promise((resolve) => signal.addEventListener('abort', resolve, { once: true }))
|
|
} finally {
|
|
closed++
|
|
}
|
|
},
|
|
})
|
|
const requests = controllers.map((controller) =>
|
|
pair.child.stream({ path: 'events' }, { signal: controller.signal, onEvent() {} })
|
|
)
|
|
const rejected = requests.map((promise) => assert.rejects(promise, /取消/))
|
|
await flush()
|
|
await assert.rejects(pair.child.stream({ path: 'events' }, { onEvent() {} }), /实时连接过多/)
|
|
assert.deepEqual(await pair.child.request({ method: 'GET', path: 'ok' }), {
|
|
code: 200,
|
|
data: 'ok',
|
|
})
|
|
for (const controller of controllers) controller.abort()
|
|
await Promise.all(rejected)
|
|
await flush()
|
|
assert.equal(closed, 2)
|
|
pair.destroy()
|
|
}
|
|
|
|
// 处理失败、超时、页面重载和退出均取消旧流,迟到事件不能更新当前页面。
|
|
for (const mode of ['callback', 'timeout', 'reload', 'logout', 'oversize']) {
|
|
const delivered = []
|
|
let signal
|
|
let closed = false
|
|
const pair = await setup({
|
|
streamTimeoutMs: mode === 'timeout' ? 20 : 300000,
|
|
stream: async function* (config) {
|
|
signal = config.signal
|
|
try {
|
|
yield {
|
|
event: 'tick',
|
|
id: '1',
|
|
data: mode === 'oversize' ? 'x'.repeat(PLUGIN_BRIDGE_MAX_BYTES) : 'one',
|
|
}
|
|
if (!signal.aborted)
|
|
await new Promise((resolve) => signal.addEventListener('abort', resolve, { once: true }))
|
|
yield { event: 'tick', id: '2', data: 'late' }
|
|
} finally {
|
|
closed = true
|
|
}
|
|
},
|
|
})
|
|
const request = pair.child.stream(
|
|
{ path: 'events' },
|
|
{
|
|
onEvent: (event) => {
|
|
delivered.push(event.id)
|
|
if (mode === 'callback') return Promise.reject(new Error('plugin callback failed'))
|
|
},
|
|
}
|
|
)
|
|
const rejected = assert.rejects(request)
|
|
await flush()
|
|
if (mode === 'logout') pair.host.destroy({ logout: true })
|
|
if (mode === 'reload') {
|
|
pair.hostWindow.dispatch({
|
|
origin: ORIGIN,
|
|
source: pair.childWindow,
|
|
data: {
|
|
namespace: 'ruoyi.plugin',
|
|
version: 1,
|
|
pluginId: 'demo',
|
|
instance: '',
|
|
type: 'ready',
|
|
payload: { clientId: 'new-stream-document' },
|
|
},
|
|
})
|
|
pair.child.destroy()
|
|
}
|
|
await rejected
|
|
await flush()
|
|
assert.equal(signal.aborted, true)
|
|
assert.equal(closed, true)
|
|
assert.deepEqual(delivered, mode === 'oversize' ? [] : ['1'])
|
|
pair.destroy()
|
|
}
|