Files
workspace/code/one-designer/packages/report-expression/tests/expression.test.ts
T
2026-09-20 17:34:40 +08:00

457 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 表达式安全性测试(对应 §4.2、§10、§14 的"表达式安全性测试")。
*
* 这是本包最重要的测试:受限求值器必须无法执行任意 JavaScript,
* 也无法通过原型链或作用域外标识符逃逸。
*/
import { describe, expect, it } from "vitest"
import { parse } from "../src/parser.js"
import { evaluateExpression, evaluateValue, compile, evaluateCompiled } from "../src/evaluator.js"
import { tokenize } from "../src/tokenizer.js"
import type { EvalScope } from "../src/scope.js"
const scope: EvalScope = {
row: { amount: 100, price: 3, quantity: 4, name: "张三", empty: null },
params: { tax: 0.1, customerName: "李四" },
index: 0,
rowNumber: 1,
}
describe("禁止任意代码执行(§4.2)", () => {
it("不允许调用未登记的函数", () => {
const result = parse("eval('1+1')")
expect(result.ast).toBeNull()
expect(result.error?.message).toContain("不允许调用函数")
})
it("eval 在解析期即被拒绝", () => {
expect(parse("eval(\"alert(1)\")").ast).toBeNull()
})
it("Function 构造器不可用", () => {
expect(parse("Function('return 1')()").ast).toBeNull()
})
it("setTimeout 等宿主 API 不可用", () => {
expect(parse("setTimeout('x', 1)").ast).toBeNull()
})
it("require / import 不可用", () => {
expect(parse("require('fs')").ast).toBeNull()
expect(parse("import('fs')").ast).toBeNull()
})
it("无法通过 constructor 逃逸", () => {
const value = evaluateValue("row.constructor", scope)
expect(value).toBeUndefined()
})
it("无法通过 __proto__ 逃逸", () => {
expect(evaluateValue("row.__proto__", scope)).toBeUndefined()
expect(evaluateValue("row['__proto__']", scope)).toBeUndefined()
})
it("无法通过 prototype 逃逸", () => {
expect(evaluateValue("row.prototype", scope)).toBeUndefined()
})
it("constructor.constructor 组合逃逸失败", () => {
expect(evaluateValue("row.constructor.constructor", scope)).toBeUndefined()
})
it("全局对象标识符解析为 undefined", () => {
for (const name of ["window", "globalThis", "process", "document", "global"]) {
expect(evaluateValue(name, scope)).toBeUndefined()
}
})
it("模板字符串语法不被支持", () => {
expect(parse("`${row.amount}`").ast).toBeNull()
})
it("箭头函数语法不被支持", () => {
expect(parse("(x) => x").ast).toBeNull()
})
it("赋值表达式不被支持", () => {
expect(parse("row.amount = 1").ast).toBeNull()
})
it("不支持语句分隔与多表达式", () => {
expect(parse("1; 2").ast).toBeNull()
})
it("不支持注释语法", () => {
expect(parse("1 // comment").ast).toBeNull()
})
it("不允许成员方法调用", () => {
// row.name.toUpperCase() 中 callee 不是简单标识符,解析失败
expect(parse("row.name.toUpperCase()").ast).toBeNull()
})
it("字符串不提供任何方法", () => {
// "张三" 是两个字符
expect(evaluateValue("row.name.length", scope)).toBe(2)
expect(evaluateValue("row.name.toUpperCase", scope)).toBeUndefined()
})
it("数组不提供任何方法", () => {
const s: EvalScope = { row: { list: [1, 2, 3] } }
expect(evaluateValue("row.list.length", s)).toBe(3)
expect(evaluateValue("row.list.map", s)).toBeUndefined()
})
it("new 关键字不被支持", () => {
expect(parse("new Date()").ast).toBeNull()
})
it("不支持 this", () => {
expect(evaluateValue("this", scope)).toBeUndefined()
})
})
describe("作用域白名单", () => {
it("只认识约定的根名", () => {
expect(evaluateValue("row.amount", scope)).toBe(100)
expect(evaluateValue("params.tax", scope)).toBe(0.1)
expect(evaluateValue("index", scope)).toBe(0)
expect(evaluateValue("rownumber", scope)).toBe(1)
})
it("未知根名返回 undefined", () => {
expect(evaluateValue("nonexistent.field", scope)).toBeUndefined()
})
it("extra 中的自定义根可访问", () => {
const s: EvalScope = { extra: { custom: { x: 7 } } }
expect(evaluateValue("custom.x", s)).toBe(7)
})
it("extra 的 Object.prototype 属性不可访问", () => {
expect(evaluateValue("toString", { extra: {} })).toBeUndefined()
})
})
describe("tokenizer", () => {
it("识别数字", () => {
const { tokens } = tokenize("1 + 2.5 + 1e3")
const nums = tokens.filter((t) => t.type === "number").map((t) => t.numberValue)
expect(nums).toEqual([1, 2.5, 1000])
})
it("拒绝小数点后无数字", () => {
expect(tokenize("1.").error?.message).toContain("小数点后必须有数字")
})
it("识别字符串与转义", () => {
const { tokens } = tokenize("'a\\nb'")
expect(tokens[0]?.stringValue).toBe("a\nb")
})
it("拒绝未闭合字符串", () => {
expect(tokenize("'abc").error?.message).toContain("未闭合")
})
it("拒绝跨行字符串", () => {
expect(tokenize("'a\nb'").error?.message).toContain("不能跨行")
})
it("识别中文标识符", () => {
const { tokens } = tokenize("row.客户名称")
expect(tokens.some((t) => t.value === "客户名称")).toBe(true)
})
it("运算符最长匹配", () => {
const { tokens } = tokenize("a >= b")
expect(tokens.some((t) => t.value === ">=")).toBe(true)
})
it("拒绝非法字符", () => {
expect(tokenize("a # b").error?.message).toContain("无法识别的字符")
})
it("记录行列位置", () => {
const { tokens } = tokenize("a +\n b")
const b = tokens.find((t) => t.value === "b")
expect(b?.line).toBe(1)
expect(b?.column).toBe(2)
})
})
describe("parser 结构与错误", () => {
it("拒绝空表达式", () => {
expect(parse("").error?.message).toContain("表达式为空")
expect(parse(" ").error?.message).toContain("表达式为空")
})
it("括号必须闭合", () => {
expect(parse("(1 + 2").error?.message).toContain("未闭合")
})
it("三元必须有冒号", () => {
expect(parse("1 ? 2").error?.message).toContain('缺少 ":"')
})
it("拒绝多余内容", () => {
expect(parse("1 2").error?.message).toContain("多余内容")
})
it("运算符优先级正确", () => {
expect(evaluateValue("1 + 2 * 3", scope)).toBe(7)
expect(evaluateValue("(1 + 2) * 3", scope)).toBe(9)
})
it("比较与逻辑优先级正确", () => {
expect(evaluateValue("1 + 1 == 2 && 3 > 1", scope)).toBe(true)
})
it("成员访问右侧必须是标识符", () => {
expect(parse("row.1").error?.message).toContain("必须是标识符")
})
it("下标必须闭合", () => {
expect(parse("row[0").error?.message).toContain('缺少 "]"')
})
})
describe("运算语义", () => {
it("算术运算", () => {
expect(evaluateValue("row.price * row.quantity", scope)).toBe(12)
expect(evaluateValue("10 / 4", scope)).toBe(2.5)
expect(evaluateValue("10 % 3", scope)).toBe(1)
})
it("字符串拼接", () => {
expect(evaluateValue("'a' + 'b'", scope)).toBe("ab")
expect(evaluateValue("row.name + '先生'", scope)).toBe("张三先生")
})
it("数字与字符串相加按拼接处理", () => {
expect(evaluateValue("1 + '2'", scope)).toBe("12")
})
it("除零返回 null 并产生诊断", () => {
const result = evaluateExpression("1 / 0", { scope })
expect(result.value).toBeNull()
expect(result.diagnostics.some((d) => d.message.includes("除数为 0"))).toBe(true)
})
it("null 参与算术视为 0", () => {
expect(evaluateValue("row.empty + 1", scope)).toBe(1)
})
it("空值不产生 NaN", () => {
const result = evaluateExpression("row.empty * 2", { scope })
expect(Number.isNaN(result.value as number)).toBe(false)
})
it("一元运算", () => {
expect(evaluateValue("-5", scope)).toBe(-5)
expect(evaluateValue("!true", scope)).toBe(false)
expect(evaluateValue("!row.empty", scope)).toBe(true)
})
it("比较运算", () => {
expect(evaluateValue("2 > 1", scope)).toBe(true)
expect(evaluateValue("'a' < 'b'", scope)).toBe(true)
expect(evaluateValue("2 >= 2", scope)).toBe(true)
})
it("相等比较", () => {
expect(evaluateValue("1 == 1", scope)).toBe(true)
expect(evaluateValue("1 == '1'", scope)).toBe(true)
expect(evaluateValue("1 === '1'", scope)).toBe(false)
expect(evaluateValue("null == null", scope)).toBe(true)
})
it("逻辑运算短路", () => {
// 右侧会触发除零诊断;短路后不应产生该诊断
const result = evaluateExpression("false && (1 / 0)", { scope })
expect(result.diagnostics).toEqual([])
})
it("?? 只在空值时取右侧", () => {
expect(evaluateValue("row.empty ?? 'fallback'", scope)).toBe("fallback")
expect(evaluateValue("row.amount ?? 0", scope)).toBe(100)
})
it("三元条件", () => {
expect(evaluateValue("row.amount > 0 ? '有' : '无'", scope)).toBe("有")
expect(evaluateValue("row.amount < 0 ? '有' : '无'", scope)).toBe("无")
})
it("嵌套成员访问", () => {
const s: EvalScope = { row: { a: { b: { c: 42 } } } }
expect(evaluateValue("row.a.b.c", s)).toBe(42)
})
it("下标访问数组", () => {
const s: EvalScope = { row: { list: [10, 20] } }
expect(evaluateValue("row.list[1]", s)).toBe(20)
expect(evaluateValue("row.list[9]", s)).toBeUndefined()
})
it("越界下标返回 undefined 而非报错", () => {
const result = evaluateExpression("row.list[9]", { scope: { row: { list: [1] } } })
expect(result.diagnostics.filter((d) => d.severity === "error")).toEqual([])
})
})
describe("内置函数", () => {
it("if 惰性求值,不执行未命中分支", () => {
const result = evaluateExpression("if(true, 1, 1 / 0)", { scope })
expect(result.value).toBe(1)
expect(result.diagnostics).toEqual([])
})
it("if 命中 else 分支", () => {
expect(evaluateValue("if(false, 'a', 'b')", scope)).toBe("b")
})
it("and / or / not", () => {
expect(evaluateValue("and(true, true)", scope)).toBe(true)
expect(evaluateValue("and(true, false)", scope)).toBe(false)
expect(evaluateValue("or(false, true)", scope)).toBe(true)
expect(evaluateValue("not(false)", scope)).toBe(true)
})
it("concat", () => {
expect(evaluateValue("concat('a', 'b', 'c')", scope)).toBe("abc")
expect(evaluateValue("concat(row.empty, 'x')", scope)).toBe("x")
})
it("round / floor / ceil / abs", () => {
expect(evaluateValue("round(1.234, 2)", scope)).toBe(1.23)
expect(evaluateValue("round(1.5)", scope)).toBe(2)
expect(evaluateValue("floor(1.9)", scope)).toBe(1)
expect(evaluateValue("ceil(1.1)", scope)).toBe(2)
expect(evaluateValue("abs(-3)", scope)).toBe(3)
})
it("min / max 忽略非数字", () => {
expect(evaluateValue("min(3, 1, 2)", scope)).toBe(1)
expect(evaluateValue("max(3, 1, 2)", scope)).toBe(3)
expect(evaluateValue("min('a', 5)", scope)).toBe(5)
})
it("len / upper / lower / trim", () => {
expect(evaluateValue("len('abc')", scope)).toBe(3)
expect(evaluateValue("upper('ab')", scope)).toBe("AB")
expect(evaluateValue("lower('AB')", scope)).toBe("ab")
expect(evaluateValue("trim(' a ')", scope)).toBe("a")
})
it("substr", () => {
expect(evaluateValue("substr('abcdef', 2)", scope)).toBe("cdef")
expect(evaluateValue("substr('abcdef', 2, 3)", scope)).toBe("cde")
})
it("replace 只做字面量替换", () => {
expect(evaluateValue("replace('a.b.c', '.', '-')", scope)).toBe("a-b-c")
})
it("replace 不接受正则元字符作为模式", () => {
// 正则语义下 '.' 应匹配任意字符,这里必须只匹配字面点
expect(evaluateValue("replace('abc', '.', 'X')", scope)).toBe("abc")
})
it("isnull / coalesce", () => {
expect(evaluateValue("isnull(row.empty)", scope)).toBe(true)
expect(evaluateValue("isnull(row.amount)", scope)).toBe(false)
expect(evaluateValue("coalesce(row.empty, '', 'x')", scope)).toBe("x")
expect(evaluateValue("coalesce(row.amount, 0)", scope)).toBe(100)
})
it("number 转失败返回 null 而非 NaN", () => {
expect(evaluateValue("number('12')", scope)).toBe(12)
expect(evaluateValue("number('abc')", scope)).toBeNull()
})
it("string 对象不隐式序列化", () => {
expect(evaluateValue("string(row)", scope)).toBe("")
})
it("format 日期", () => {
expect(evaluateValue("format('2024-03-05T08:09:10Z', 'YYYY-MM-DD')", scope)).toBe("2024-03-05")
})
it("format 数字千分位", () => {
expect(evaluateValue("format(1234567.891, '#,##0.00')", scope)).toBe("1,234,567.89")
})
it("format 百分比", () => {
expect(evaluateValue("format(0.1234, '0.0%')", scope)).toBe("12.3%")
})
it("format 负数千分位", () => {
expect(evaluateValue("format(-1234.5, '#,##0.00')", scope)).toBe("-1,234.50")
})
it("date 返回可序列化的 ISO 字符串", () => {
const value = evaluateValue("date('2024-03-05T08:09:10Z')", scope)
expect(typeof value).toBe("string")
expect(value).toBe("2024-03-05T08:09:10.000Z")
})
it("表达式结果永远是纯数据(不含 Date 对象)", () => {
const value = evaluateValue("date('2024-01-01')", scope)
expect(value instanceof Date).toBe(false)
})
})
describe("错误处理(§10 单个字段错误不影响整体)", () => {
it("解析失败返回诊断而非抛异常", () => {
const result = evaluateExpression("1 +", { scope })
expect(result.value).toBeNull()
expect(result.diagnostics).toHaveLength(1)
expect(result.diagnostics[0]?.code).toBe("expression.parse-error")
})
it("诊断携带元素 ID 与路径", () => {
const result = evaluateExpression("1 +", {
scope,
elementId: "el-1",
path: "/sections/1/children/0/binding/expression",
})
expect(result.diagnostics[0]?.elementId).toBe("el-1")
expect(result.diagnostics[0]?.path).toBe("/sections/1/children/0/binding/expression")
})
it("函数内部异常被捕获", () => {
const result = evaluateExpression("round('abc')", { scope })
expect(result.diagnostics.filter((d) => d.severity === "error")).toEqual([])
})
it("诊断不包含原始表达式外的敏感内容", () => {
const result = evaluateExpression("nosuchfn(1)", { scope })
expect(result.diagnostics[0]?.message).toBeTruthy()
})
})
describe("编译与缓存", () => {
it("compile 返回可复用的 AST", () => {
const c = compile("row.amount + 1")
expect(c.ast).not.toBeNull()
expect(c.error).toBeNull()
})
it("compile 记录解析错误", () => {
const c = compile("1 +")
expect(c.ast).toBeNull()
expect(c.error).toBeTruthy()
})
it("evaluateCompiled 复用 AST", () => {
const c = compile("row.amount * 2")
expect(evaluateCompiled(c, { scope }).value).toBe(200)
expect(evaluateCompiled(c, { scope: { row: { amount: 5 } } }).value).toBe(10)
})
it("evaluateCompiled 对坏表达式返回诊断", () => {
const result = evaluateCompiled(compile("1 +"), { scope })
expect(result.value).toBeNull()
expect(result.diagnostics).toHaveLength(1)
})
})