1
29import { describe, expect, it } from "vitest";
30import fc from "fast-check";
31import {
32 parseFormula,
33 evaluateFormula,
34 FormulaError,
35 EVAL_OP_LIMIT,
36 EVAL_TIMEOUT_MS
37} from "@factory/database-engine/formula";
38import type { FormulaContext } from "@factory/database-engine/formula";
39
40
41
42
43const BASE_SEED = process.env.SEC_FUZZ_SEED === "random" ? (Date.now() & 0xffff) : 0x134_f00d;
44
45
46
47
48
49
50const FUNC_ARITY: Record<string, number> = {
51 if: 3, and: 3, or: 3, not: 1, sum: 1, average: 1, min: 1, max: 1,
52 dateAdd: 3, dateBetween: 2, now: 0, concat: 3, replace: 3, contains: 2,
53 length: 1, prop: 1
54};
55const FUNC_NAMES = Object.keys(FUNC_ARITY);
56
57const KNOWN_PROPS: Record<string, unknown> = {
58 Amount: 42, Price: 19.99, Status: "Done", Title: "Hello", Note: "a string",
59 DueDate: "2025-06-15", Started: "2025-06-01", Active: true, Tags: ["a", "b"]
60};
61const KNOWN_TYPES: Record<string, string> = {
62 Amount: "number", Price: "number", Status: "text", Title: "title",
63 Note: "text", DueDate: "date", Started: "date", Active: "checkbox", Tags: "multi_select"
64};
65const PROP_NAMES = Object.keys(KNOWN_PROPS);
66const UNKNOWN_PROPS = ["Statu", "Quantiti", "Duedate", "Isdone", "Xyz", "amount2"];
67
68const OPS = ["+", "-", "*", "/", "==", "!=", "<", "<=", ">", ">=", "and", "or"] as const;
69const STR_LITS = ['"a"', '"b"', '"Done"', '"x"', '"Open"'] as const;
70const DATE_LITS = ['"2025-06-01"', '"2025-07-01"', '"2025-06-15"', "now()"] as const;
71const PROP_ALPHA = "abcdefghijklmnopqrstuvwxyz0123456789";
72
73
74
75
76function makeRng(seed: number): () => number {
77 let s = (seed >>> 0) || 0x9e3779b9;
78 return () => {
79 s ^= s << 13; s >>>= 0;
80 s ^= s >> 17;
81 s ^= s << 5; s >>>= 0;
82 return s / 0x100000000;
83 };
84}
85function pick<T>(rng: () => number, arr: readonly T[]): T {
86 return arr[Math.floor(rng() * arr.length)]!;
87}
88
89
90function propName(rng: () => number): string {
91 const r = rng();
92 if (r < 0.5) return pick(rng, PROP_NAMES);
93 if (r < 0.8) return pick(rng, UNKNOWN_PROPS);
94 const n = 1 + Math.floor(rng() * 8);
95 let out = "";
96 for (let i = 0; i < n; i++) out += PROP_ALPHA[Math.floor(rng() * PROP_ALPHA.length)]!;
97 return out;
98}
99
100function leaf(rng: () => number): string {
101 const r = rng();
102 if (r < 0.3) return String(Math.floor(rng() * 2001) - 1000);
103 if (r < 0.42) return rng() < 0.5 ? "true" : "false";
104 if (r < 0.5) return "null";
105 if (r < 0.64) return pick(rng, STR_LITS);
106 if (r < 0.76) return pick(rng, DATE_LITS);
107 return `prop("${propName(rng)}")`;
108}
109
110
116function genExpr(rng: () => number, depth: number, budget: { left: number }): string {
117 budget.left--;
118 if (depth <= 1 || budget.left <= 0) return leaf(rng);
119 const r = rng();
120 if (r < 0.45) return leaf(rng);
121 if (r < 0.8) {
122 const l = genExpr(rng, depth - 1, budget);
123 const op = pick(rng, OPS);
124 const rr = genExpr(rng, depth - 1, budget);
125 return `(${l} ${op} ${rr})`;
126 }
127 const name = pick(rng, FUNC_NAMES);
128 const arity = FUNC_ARITY[name]!;
129 if (arity === 0) return `${name}()`;
130 const args: string[] = [];
131 for (let i = 0; i < arity; i++) {
132 args.push(name === "prop" ? `prop("${propName(rng)}")` : genExpr(rng, depth - 1, budget));
133 }
134 return `${name}(${args.join(", ")})`;
135}
136
137function ctxFor(): FormulaContext {
138 return {
139 now: Date.parse("2025-06-15T12:00:00Z"),
140 properties: { ...KNOWN_PROPS },
141 propertyTypes: { ...KNOWN_TYPES }
142 };
143}
144
145const ALLOWED_CODES = new Set(["PARSE", "TYPE", "RUNTIME", "UNKNOWN_FUNCTION", "FORMULA_TIMEOUT"]);
146
147function assertFormulaError(e: unknown, where: string, src: string): void {
148 if (!(e instanceof FormulaError)) {
149 throw new Error(
150 `[formula-fuzz] ${where} threw a non-FormulaError (a crash — the engine must not leak ` +
151 `TypeError/RangeError): ${e instanceof Error ? e.name + ": " + e.message : String(e)} | src=${src}`
152 );
153 }
154 if (!ALLOWED_CODES.has(e.code)) {
155 throw new Error(`[formula-fuzz] unexpected FormulaError code "${e.code}" | src=${src}`);
156 }
157}
158
159describe("TASK-0134 formula fuzz (10k expressions, no crash / no hang)", () => {
160 it("10,000 random expressions: only FormulaError may escape, always bounded", () => {
161 const t0 = Date.now();
162 let ok = 0;
163 let typedErrors = 0;
164 let timeouts = 0;
165 let parseErrors = 0;
166 let maxMs = 0;
167
168 fc.assert(
169 fc.property(
170 fc.tuple(fc.nat(), fc.nat(), fc.nat()),
171 ([a, b, c]) => {
172 const rng = makeRng(((a ^ Math.imul(b, 0x9e3779b9) ^ Math.imul(c, 0x85ebca6b)) >>> 0) | BASE_SEED);
173 const depth = 1 + (a % 40);
174 const src = genExpr(rng, depth, { left: 2000 });
175 const perEvalT0 = Date.now();
176 let ast: unknown;
177 try {
178 ast = parseFormula(src);
179 } catch (e) {
180 assertFormulaError(e, "parse", src);
181 if (e instanceof FormulaError && e.code === "PARSE") parseErrors++;
182 typedErrors++;
183 maxMs = Math.max(maxMs, Date.now() - perEvalT0);
184 return;
185 }
186 try {
187 const r = evaluateFormula(ast as never, ctxFor());
188 expect(typeof r.steps).toBe("number");
189 ok++;
190 } catch (e) {
191 assertFormulaError(e, "eval", src);
192 typedErrors++;
193 if (e instanceof FormulaError && e.code === "FORMULA_TIMEOUT") timeouts++;
194 }
195 maxMs = Math.max(maxMs, Date.now() - perEvalT0);
196 }
197 ),
198 { numRuns: 10_000, seed: BASE_SEED, timeout: 120_000 }
199 );
200
201 const totalMs = Date.now() - t0;
202
203
204 expect(totalMs).toBeLessThan(120_000);
205
206
207
208 expect(ok + typedErrors).toBe(10_000);
209 expect(typedErrors).toBeGreaterThan(0);
210 expect(parseErrors).toBeGreaterThan(0);
211 console.log(
212 `[formula-fuzz] baseSeed=${BASE_SEED} ok=${ok} typedErrors=${typedErrors} (parse=${parseErrors}, timeouts=${timeouts}) maxEvalMs=${maxMs} totalMs=${totalMs}`
213 );
214 });
215
216 it("worst-case family: oversized expression hits FORMULA_TIMEOUT (op limit), never hangs", () => {
217
218
219
220
221 const N = EVAL_OP_LIMIT + 500;
222 let src = "and(";
223 for (let i = 0; i < N; i++) src += "true, ";
224 src = src.slice(0, -2) + ")";
225
226 const t0 = Date.now();
227 let caught: unknown;
228 try {
229 evaluateFormula(parseFormula(src) as never, ctxFor(), { now: () => 0 });
230 } catch (e) {
231 caught = e;
232 }
233 const elapsed = Date.now() - t0;
234 expect(caught).toBeInstanceOf(FormulaError);
235 expect((caught as FormulaError).code).toBe("FORMULA_TIMEOUT");
236 expect((caught as FormulaError).message).toMatch(/op limit/i);
237 expect(elapsed).toBeLessThan(2_000);
238
239
240
241 expect(EVAL_TIMEOUT_MS).toBe(50);
242 expect(EVAL_OP_LIMIT).toBe(100_000);
243 });
244
245 it("wide concat family (many prop refs) is bounded by the output/op guard", () => {
246
247
248
249
250
251 const parts: string[] = [];
252 for (let i = 0; i < 200_000; i++) parts.push(`prop("Note")`);
253 const src = `concat(${parts.join(", ")})`;
254 const t0 = Date.now();
255 let caught: unknown;
256 let value: unknown;
257 try {
258 const r = evaluateFormula(parseFormula(src) as never, ctxFor(), { now: () => 0 });
259 value = r.value.v;
260 } catch (e) {
261 caught = e;
262 }
263 const elapsed = Date.now() - t0;
264 if (caught !== undefined) {
265 expect(caught).toBeInstanceOf(FormulaError);
266 expect(ALLOWED_CODES.has((caught as FormulaError).code)).toBe(true);
267 } else {
268 expect(typeof value).toBe("string");
269 }
270 expect(elapsed).toBeLessThan(2_000);
271 });
272});
273