@republicroad/zen-udf 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +80 -0
  3. package/docs/naming.md +40 -0
  4. package/package.json +24 -0
  5. package/src/context-hardening.test.ts +93 -0
  6. package/src/contrib/crypto.test.ts +60 -0
  7. package/src/contrib/crypto.ts +74 -0
  8. package/src/contrib/custom-list-query.ts +51 -0
  9. package/src/contrib/debug.ts +42 -0
  10. package/src/contrib/debugui.test.ts +10 -0
  11. package/src/contrib/debugui.ts +27 -0
  12. package/src/contrib/http-guard.test.ts +116 -0
  13. package/src/contrib/http.test.ts +230 -0
  14. package/src/contrib/http.ts +283 -0
  15. package/src/contrib/ip-location.ts +82 -0
  16. package/src/contrib/legacy-graph-acceptance.test.ts +67 -0
  17. package/src/contrib/rate-store-conformance.ts +68 -0
  18. package/src/contrib/rate-store.test.ts +30 -0
  19. package/src/contrib/rate-window.ts +195 -0
  20. package/src/contrib/rebuilt-functions.test.ts +60 -0
  21. package/src/contrib/roster.test.ts +27 -0
  22. package/src/contrib/roster.ts +38 -0
  23. package/src/decision-cache.test.ts +122 -0
  24. package/src/decision-cache.ts +104 -0
  25. package/src/decision-runtime.test.ts +154 -0
  26. package/src/engine-cache-semantics.test.ts +87 -0
  27. package/src/engine.ts +474 -0
  28. package/src/exec-context.test.ts +52 -0
  29. package/src/exec-context.ts +32 -0
  30. package/src/execution-spec.test.ts +173 -0
  31. package/src/index.ts +43 -0
  32. package/src/limiter.ts +70 -0
  33. package/src/reference.ts +34 -0
  34. package/src/register.test.ts +60 -0
  35. package/src/register.ts +515 -0
  36. package/src/roster.test.ts +96 -0
  37. package/src/roster.ts +129 -0
  38. package/src/sanitize.test.ts +65 -0
  39. package/src/udf-pack.test.ts +107 -0
  40. package/src/udf-trace.test.ts +137 -0
@@ -0,0 +1,515 @@
1
+ /**
2
+ * 完整 JSON Schema 属性(与 brdeapi.geetest.com/zen_custom_node_function.json 对齐)。
3
+ * index signature 允许嵌套 schema(properties/items/$defs/anyOf 等)。
4
+ */
5
+ export interface JsonSchemaProperty {
6
+ type?: string;
7
+ title?: string;
8
+ description?: string;
9
+ default?: unknown;
10
+ anyOf?: JsonSchemaProperty[];
11
+ items?: JsonSchemaProperty;
12
+ properties?: Record<string, JsonSchemaProperty>;
13
+ required?: string[];
14
+ additionalProperties?: boolean | JsonSchemaProperty;
15
+ $ref?: string;
16
+ $defs?: Record<string, JsonSchemaProperty>;
17
+ enum?: unknown[];
18
+ format?: string;
19
+ [key: string]: unknown;
20
+ }
21
+
22
+ export interface JsonSchema {
23
+ type?: string;
24
+ title?: string;
25
+ description?: string;
26
+ default?: unknown;
27
+ anyOf?: JsonSchema[];
28
+ items?: JsonSchema;
29
+ properties?: Record<string, JsonSchemaProperty>;
30
+ required?: string[];
31
+ additionalProperties?: boolean | JsonSchemaProperty;
32
+ $ref?: string;
33
+ $defs?: Record<string, JsonSchemaProperty>;
34
+ [key: string]: unknown;
35
+ }
36
+
37
+ /** 扁平参数 schema(执行/绑定用,funcBindParams 依赖) */
38
+ export interface UdfSchemaParameter {
39
+ type?: string;
40
+ description?: string;
41
+ default?: unknown;
42
+ }
43
+
44
+ /** 单个自定义函数(namespace/tools 格式中的 tool),对应 createJdmNode 的 kind */
45
+ export interface CustomFunctionTool {
46
+ name: string;
47
+ title: string;
48
+ type: 'function';
49
+ description?: string;
50
+ parameters: {
51
+ properties: Record<string, JsonSchemaProperty>;
52
+ required?: string[];
53
+ title?: string;
54
+ type?: 'object';
55
+ };
56
+ returns: JsonSchema;
57
+ namespace: string;
58
+ kind: string;
59
+ }
60
+
61
+ /** 自定义节点命名空间(namespace/tools 格式),对应侧边栏 group */
62
+ export interface CustomNodeNamespace {
63
+ /** 恒为 'namespace'(集合容器档;契约字段保留供未来场景) */
64
+ type: 'namespace';
65
+ title: string;
66
+ name: string;
67
+ description?: string;
68
+ tools: CustomFunctionTool[];
69
+ }
70
+
71
+ /** UDF 声明 schema(向后兼容:扁平 parameters 与完整 parametersSchema 二选一或并存) */
72
+ export interface UdfSchema {
73
+ parameters?: Record<string, UdfSchemaParameter>;
74
+ returns?: { type?: string; description?: string };
75
+ namespace?: string;
76
+ /** 完整 JSON Schema 形式的参数定义(用于 /api/custom-nodes/schema 下发) */
77
+ parametersSchema?: {
78
+ properties: Record<string, JsonSchemaProperty>;
79
+ required?: string[];
80
+ title?: string;
81
+ type?: 'object';
82
+ };
83
+ /** 完整 JSON Schema 形式的返回值定义 */
84
+ returnsSchema?: JsonSchema;
85
+ description?: string;
86
+ }
87
+
88
+ interface UdfEntry {
89
+ fn: UdfFunction;
90
+ schema: UdfSchema;
91
+ }
92
+
93
+ /** 可注册的 UDF 函数签名(动态注册表,运行时统一以单个 kwargs 对象调用) */
94
+ type UdfFunction = (kwargs: Record<string, unknown>) => unknown;
95
+
96
+ /** JSON Schema type 语义匹配(校验用;'any'/'null' 恒真,未知类型不判违例) */
97
+ function matchJsonType(value: unknown, type: string): boolean {
98
+ switch (type) {
99
+ case 'string':
100
+ return typeof value === 'string';
101
+ case 'boolean':
102
+ return typeof value === 'boolean';
103
+ case 'integer':
104
+ return typeof value === 'number' && Number.isInteger(value);
105
+ case 'number':
106
+ return typeof value === 'number' && Number.isFinite(value);
107
+ case 'object':
108
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
109
+ case 'array':
110
+ return Array.isArray(value);
111
+ default:
112
+ return true;
113
+ }
114
+ }
115
+
116
+ function describeJsonType(value: unknown): string {
117
+ if (value === null) return 'null';
118
+ if (Array.isArray(value)) return 'array';
119
+ return typeof value;
120
+ }
121
+
122
+ function jsonT2pyT(jsonType: string): (v: unknown) => unknown {
123
+ const m: Record<string, (v: unknown) => unknown> = {
124
+ null: () => null,
125
+ any: (v) => v,
126
+ boolean: (v) => Boolean(v),
127
+ string: (v) => (v === null || v === undefined ? '' : String(v)),
128
+ object: (v) => (typeof v === 'object' && v !== null ? v : {}),
129
+ array: (v) => (Array.isArray(v) ? v : []),
130
+ integer: (v) => {
131
+ const n = Number(v);
132
+ return Number.isInteger(n) ? n : 0;
133
+ },
134
+ number: (v) => Number(v),
135
+ };
136
+ return m[jsonType] ?? ((v) => v);
137
+ }
138
+
139
+ /**
140
+ * 归一化 UdfSchema:
141
+ * - 提供了 parametersSchema 时,自动派生扁平 parameters(供 funcBindParams 绑定/执行)
142
+ * - 只提供扁平 parameters 时,自动合成 parametersSchema(供 schema 下发,保持旧调用方兼容)
143
+ */
144
+ function normalizeUdfSchema(schema: UdfSchema): UdfSchema {
145
+ const normalized: UdfSchema = {
146
+ parameters: schema.parameters ?? {},
147
+ returns: schema.returns ?? { type: 'null' },
148
+ namespace: schema.namespace ?? 'default',
149
+ description: schema.description,
150
+ };
151
+
152
+ if (schema.parametersSchema) {
153
+ normalized.parametersSchema = schema.parametersSchema;
154
+ if (!normalized.parameters || Object.keys(normalized.parameters).length === 0) {
155
+ const derived: Record<string, UdfSchemaParameter> = {};
156
+ for (const [name, prop] of Object.entries(schema.parametersSchema.properties)) {
157
+ derived[name] = {
158
+ type: typeof prop.type === 'string' ? prop.type : 'null',
159
+ description: prop.description,
160
+ default: prop.default,
161
+ };
162
+ }
163
+ normalized.parameters = derived;
164
+ }
165
+ } else if (schema.parameters && Object.keys(schema.parameters).length > 0) {
166
+ const synthesized: {
167
+ properties: Record<string, JsonSchemaProperty>;
168
+ required: string[];
169
+ title: string;
170
+ type: 'object';
171
+ } = {
172
+ properties: {},
173
+ required: [],
174
+ title: '',
175
+ type: 'object',
176
+ };
177
+ for (const [name, param] of Object.entries(schema.parameters)) {
178
+ synthesized.properties[name] = {
179
+ type: param.type,
180
+ description: param.description,
181
+ default: param.default,
182
+ };
183
+ if (param.default === undefined) {
184
+ synthesized.required.push(name);
185
+ }
186
+ }
187
+ normalized.parametersSchema = synthesized;
188
+ }
189
+
190
+ if (schema.returnsSchema) {
191
+ normalized.returnsSchema = schema.returnsSchema;
192
+ if (!normalized.returns || normalized.returns.type === undefined) {
193
+ normalized.returns = {
194
+ type: schema.returnsSchema.type,
195
+ description: schema.returnsSchema.description,
196
+ };
197
+ }
198
+ }
199
+
200
+ return normalized;
201
+ }
202
+
203
+ class UdfRegistry {
204
+ private functions = new Map<string, UdfEntry>();
205
+
206
+ /**
207
+ * 平台硬化:跨名冲突校验——裸 kind 解析中 namespace 优先,函数名/namespace 交叉同名
208
+ * 会使其中一方 kind 不可达,注册期直接失败(force 可显式接管)。
209
+ * 函数名与自身 namespace 同名(如 contrib/roster.ts 的 roster 工具)为遗留既定契约,放行:
210
+ * 语义确定为 namespace 优先。
211
+ */
212
+ private assertNoNamespaceCollision(name: string, namespace: string): void {
213
+ const existingNamespaces = new Set<string>();
214
+ for (const entry of this.functions.values()) {
215
+ existingNamespaces.add(entry.schema.namespace ?? 'default');
216
+ }
217
+ if (name !== namespace && existingNamespaces.has(name)) {
218
+ throw new Error(
219
+ `[udf] 函数 '${name}' 与现有 namespace 同名:裸 kind 解析时 namespace 优先,函数锁定 kind 不可达(force 可显式接管)`,
220
+ );
221
+ }
222
+ if (name !== namespace && [...this.functions.keys()].some((fnName) => fnName === namespace)) {
223
+ throw new Error(
224
+ `[udf] namespace '${namespace}' 与现有函数同名:其中函数的裸 kind 解析将命中 namespace(force 可显式接管)`,
225
+ );
226
+ }
227
+ }
228
+
229
+ registerFunction(
230
+ fn: UdfFunction,
231
+ namespace?: string,
232
+ schema?: UdfSchema,
233
+ nameOverride?: string,
234
+ options?: { force?: boolean },
235
+ ): void {
236
+ const name = nameOverride ?? fn.name;
237
+ if (!name) {
238
+ throw new Error('Function must have a name to register');
239
+ }
240
+ if (!options?.force) {
241
+ this.assertNoNamespaceCollision(name, namespace ?? 'default');
242
+ }
243
+ this.functions.set(name, {
244
+ fn,
245
+ schema: normalizeUdfSchema({
246
+ parameters: schema?.parameters ?? {},
247
+ returns: schema?.returns ?? { type: 'null' },
248
+ namespace: namespace ?? 'default',
249
+ parametersSchema: schema?.parametersSchema,
250
+ returnsSchema: schema?.returnsSchema,
251
+ description: schema?.description,
252
+ }),
253
+ });
254
+ }
255
+
256
+ /** 批量注册工具定义(UdfPack / reference 域装载共用;namespace 缺省 'default') */
257
+ registerTools(defs: ContribToolDef[], namespace?: string): void {
258
+ for (const def of defs) {
259
+ this.registerFunction(
260
+ def.fn,
261
+ namespace,
262
+ {
263
+ description: def.description,
264
+ parametersSchema: def.parametersSchema,
265
+ returnsSchema: def.returnsSchema,
266
+ },
267
+ def.name,
268
+ );
269
+ }
270
+ }
271
+
272
+ /**
273
+ * 位置参数前置校验(执行规范 §6.1):对已求值、未绑定的位置参数检查必填项。
274
+ * 返回错误清单(空数组 = 通过)。缺省参数在 funcBindParams 中回退,不算缺失。
275
+ */
276
+ validatePositionalArgs(name: string, args: unknown[]): string[] {
277
+ const schema = this.functions.get(name)?.schema;
278
+ if (!schema?.parameters) return [];
279
+ const issues: string[] = [];
280
+ Object.entries(schema.parameters).forEach(([paramName, paramSchema], i) => {
281
+ if (i >= args.length) return; // 越界位置由 funcBindParams 以默认值补齐
282
+ if (paramSchema.default !== undefined) return; // 有默认值 = 非必填
283
+ const value = args[i];
284
+ if (value === undefined || value === null) {
285
+ issues.push(`${paramName} is required (position ${i})`);
286
+ }
287
+ });
288
+ return issues;
289
+ }
290
+
291
+ /**
292
+ * 返回值契约校验(执行规范 §6.5):按 returnsSchema 顶层断言
293
+ * (type / required / properties 浅层类型)。返回错误清单(空数组 = 通过)。
294
+ */
295
+ validateResult(name: string, result: unknown): string[] {
296
+ const schema = this.functions.get(name)?.schema;
297
+ const rs = schema?.returnsSchema;
298
+ if (!rs) return [];
299
+ const issues: string[] = [];
300
+ if (typeof rs.type === 'string' && rs.type !== 'any' && rs.type !== 'null' && !matchJsonType(result, rs.type)) {
301
+ issues.push(`result type expected ${rs.type}, got ${describeJsonType(result)}`);
302
+ }
303
+ if (result !== null && typeof result === 'object' && !Array.isArray(result)) {
304
+ const record = result as Record<string, unknown>;
305
+ for (const key of rs.required ?? []) {
306
+ if (record[key] === undefined) {
307
+ issues.push(`result.${key} is required`);
308
+ }
309
+ }
310
+ for (const [key, prop] of Object.entries(rs.properties ?? {})) {
311
+ const value = record[key];
312
+ if (value === undefined) continue;
313
+ const propType = typeof prop?.type === 'string' ? prop.type : undefined;
314
+ if (propType && propType !== 'any' && !matchJsonType(value, propType)) {
315
+ issues.push(`result.${key} type expected ${propType}, got ${describeJsonType(value)}`);
316
+ }
317
+ }
318
+ }
319
+ return issues;
320
+ }
321
+
322
+ udfFunctionSchema(name: string): UdfSchema | undefined {
323
+ return this.functions.get(name)?.schema;
324
+ }
325
+
326
+ funcBindParams(name: string, args: unknown[]): Record<string, unknown> {
327
+ const schema = this.udfFunctionSchema(name);
328
+ if (!schema?.parameters) {
329
+ return {};
330
+ }
331
+ const paramEntries = Object.entries(schema.parameters);
332
+ const bound: Record<string, unknown> = {};
333
+ paramEntries.forEach(([paramName, paramSchema], i) => {
334
+ const val = i < args.length ? args[i] : (paramSchema.default ?? null);
335
+ const converter = jsonT2pyT(paramSchema.type ?? 'null');
336
+ bound[paramName] = converter(val);
337
+ });
338
+ return bound;
339
+ }
340
+
341
+ async call(udfName: string, ...args: unknown[]): Promise<unknown> {
342
+ const entry = this.functions.get(udfName);
343
+ if (!entry) {
344
+ throw new Error(`Function '${udfName}' is not registered in UdfRegistry`);
345
+ }
346
+ const kwargs = (args[0] as Record<string, unknown> | undefined) ?? {};
347
+ const result = entry.fn(kwargs);
348
+ return result instanceof Promise ? await result : result;
349
+ }
350
+
351
+ /** 扁平 schema 数组(旧接口,保持兼容) */
352
+ udfFunctionSchemaTools(): unknown[] {
353
+ const funcTools: unknown[] = [];
354
+ for (const entry of this.functions.values()) {
355
+ funcTools.push(entry.schema);
356
+ }
357
+ return funcTools;
358
+ }
359
+
360
+ /**
361
+ * namespace 分组 + tools 格式,与 brdeapi.geetest.com/zen_custom_node_function.json 对齐。
362
+ * 每个 namespace 对应侧边栏 group,每个 tool 对应 createJdmNode 的 kind。
363
+ * type 恒为 'namespace'(集合容器档;契约字段保留供未来场景)。
364
+ */
365
+ udfFunctionSchemaNamespaces(): CustomNodeNamespace[] {
366
+ const namespaces = new Map<string, CustomNodeNamespace>();
367
+ for (const [name, entry] of this.functions.entries()) {
368
+ const ns = entry.schema.namespace ?? 'default';
369
+ let nsObj = namespaces.get(ns);
370
+ if (!nsObj) {
371
+ nsObj = {
372
+ type: 'namespace',
373
+ title: ns,
374
+ name: ns,
375
+ description: '',
376
+ tools: [],
377
+ };
378
+ namespaces.set(ns, nsObj);
379
+ }
380
+ nsObj.tools.push({
381
+ name,
382
+ title: name,
383
+ type: 'function',
384
+ description: entry.schema.description ?? '',
385
+ parameters: entry.schema.parametersSchema ?? {
386
+ properties: {},
387
+ title: name,
388
+ type: 'object',
389
+ },
390
+ returns: entry.schema.returnsSchema ?? { type: 'null', title: '', properties: {} },
391
+ namespace: ns,
392
+ kind: ns,
393
+ });
394
+ }
395
+ return [...namespaces.values()];
396
+ }
397
+ }
398
+
399
+ const globalUdfRegistry = new UdfRegistry();
400
+
401
+ function registerUdf(name: string, namespace?: string, schema?: UdfSchema): (fn: UdfFunction) => UdfFunction {
402
+ return (fn: UdfFunction) => {
403
+ globalUdfRegistry.registerFunction(fn, namespace, schema, name);
404
+ return fn;
405
+ };
406
+ }
407
+
408
+ /**
409
+ * ext 扩展文件专用注册器(ext 约定:文件名即 namespace,函数缺省注册到该 namespace)。
410
+ * 用法:const registerUdf = createExtRegister(import.meta.url); 之后 registerUdf(name, schema)(fn)。
411
+ * 需要显式指定 namespace 时使用全局 registerUdf(name, namespace, schema)。
412
+ */
413
+ export function createExtRegister(importMetaUrl: string) {
414
+ const namespace = decodeURIComponent(importMetaUrl.split('/').pop() ?? '').replace(/\.[^.]+$/, '');
415
+ return (name: string, schema?: UdfSchema): ((fn: UdfFunction) => UdfFunction) => registerUdf(name, namespace, schema);
416
+ }
417
+
418
+ /** contrib 域单工具定义(defineContrib 数组项;字段与 UdfSchema 注册参数一致) */
419
+ export interface ContribToolDef {
420
+ name: string;
421
+ description?: string;
422
+ parametersSchema?: UdfSchema['parametersSchema'];
423
+ returnsSchema?: UdfSchema['returnsSchema'];
424
+ fn: UdfFunction;
425
+ }
426
+
427
+ /** contrib 域定义(defineContrib 的入参) */
428
+ export interface ContribDef {
429
+ tools: ContribToolDef[];
430
+ }
431
+
432
+ /**
433
+ * contrib 域单调用注册(第七十七批 ergonomics):文件名即 namespace,tools 逐个挂载。
434
+ * 返回传入的 tools(便于测试断言与再导出)。旧 createExtRegister/registerUdf 签名保留向后兼容。
435
+ */
436
+ export function defineContrib(importMetaUrl: string, def: ContribDef): ContribToolDef[] {
437
+ const namespace = decodeURIComponent(importMetaUrl.split('/').pop() ?? '').replace(/\.[^.]+$/, '');
438
+ for (const tool of def.tools) {
439
+ registerUdf(tool.name, namespace, {
440
+ description: tool.description,
441
+ parametersSchema: tool.parametersSchema,
442
+ returnsSchema: tool.returnsSchema,
443
+ })(tool.fn);
444
+ }
445
+ return def.tools;
446
+ }
447
+
448
+ /** 单工具声明助手:为字面量提供 ContribToolDef 类型检查与补全 */
449
+ export const defineTool = (tool: ContribToolDef): ContribToolDef => tool;
450
+
451
+ /**
452
+ * UdfPack:宿主业务函数包契约(verdict 等仓以纯数据 + 处理器形态注入)。
453
+ * namespace 对应编辑器侧边栏 group 与 customNode 的 kind 域;注册是 deploy-time
454
+ * 静态行为,租户差异在调用时经 ExecContext/端口解析,禁止 per-tenant 注册。
455
+ */
456
+ export interface UdfPack {
457
+ namespace: string;
458
+ tools: ContribToolDef[];
459
+ }
460
+
461
+ /** 校验 UdfPack 形状,返回错误清单(空数组 = 通过)。createUdfRegistry 注册前自动调用 */
462
+ export function validatePack(pack: UdfPack): string[] {
463
+ const errors: string[] = [];
464
+ if (!pack.namespace || typeof pack.namespace !== 'string') {
465
+ errors.push('namespace is required and must be a non-empty string');
466
+ }
467
+ if (!Array.isArray(pack.tools) || pack.tools.length === 0) {
468
+ errors.push('tools must be a non-empty array');
469
+ return errors;
470
+ }
471
+ const seen = new Set<string>();
472
+ for (const tool of pack.tools) {
473
+ if (!tool || typeof tool.name !== 'string' || !tool.name) {
474
+ errors.push('every tool requires a non-empty string name');
475
+ continue;
476
+ }
477
+ if (typeof tool.fn !== 'function') {
478
+ errors.push(`tool '${tool.name}' requires a function fn`);
479
+ }
480
+ if (tool.parametersSchema && typeof tool.parametersSchema !== 'object') {
481
+ errors.push(`tool '${tool.name}' parametersSchema must be an object`);
482
+ }
483
+ if (tool.parametersSchema && !tool.parametersSchema.properties) {
484
+ errors.push(`tool '${tool.name}' parametersSchema.properties is required`);
485
+ }
486
+ if (seen.has(tool.name)) {
487
+ errors.push(`duplicate tool name '${tool.name}' within pack`);
488
+ }
489
+ seen.add(tool.name);
490
+ }
491
+ return errors;
492
+ }
493
+
494
+ export interface CreateUdfRegistryOptions {
495
+ /** 业务函数包(deploy-time 注入);注册前逐个 validatePack,违例整体失败 */
496
+ packs?: UdfPack[];
497
+ }
498
+
499
+ /**
500
+ * 构建隔离的 UdfRegistry 实例(U6):多运行时/多租户实例注入的推荐入口。
501
+ * 参考函数域按需经 loadReferenceInto(registry) 装载(builtin: 'reference' 语义)。
502
+ */
503
+ export function createUdfRegistry(options: CreateUdfRegistryOptions = {}): UdfRegistry {
504
+ const registry = new UdfRegistry();
505
+ for (const pack of options.packs ?? []) {
506
+ const errors = validatePack(pack);
507
+ if (errors.length > 0) {
508
+ throw new Error(`[udf] invalid UdfPack '${pack.namespace}': ${errors.join('; ')}`);
509
+ }
510
+ registry.registerTools(pack.tools, pack.namespace);
511
+ }
512
+ return registry;
513
+ }
514
+
515
+ export { UdfRegistry, globalUdfRegistry, registerUdf };
@@ -0,0 +1,96 @@
1
+ import { describe, expect, test } from 'vitest';
2
+
3
+ import { deleteRoster, getRoster, listRosters, queryRoster, registerRoster } from './roster.ts';
4
+
5
+ const T1 = { tenantId: 't-1' };
6
+ const T2 = { tenantId: 't-2' };
7
+
8
+ describe('roster 基础存取(U5 租户作用域)', () => {
9
+ test('注册/覆盖/删除(租户共享)', () => {
10
+ registerRoster({ name: 't_list', description: '测试名单', items: ['a', 'b'] }, T1);
11
+ expect(getRoster('t_list', T1)).toEqual({ name: 't_list', description: '测试名单', items: ['a', 'b'] });
12
+
13
+ registerRoster({ name: 't_list', items: ['c'] }, T1);
14
+ expect(getRoster('t_list', T1)?.items).toEqual(['c']);
15
+
16
+ expect(deleteRoster('t_list', T1)).toBe(true);
17
+ });
18
+
19
+ test('listRosters 支持名称大小写不敏感过滤(限本租户)', () => {
20
+ registerRoster({ name: 'Alpha_List', items: [] }, T1);
21
+ registerRoster({ name: 'beta-list', items: [] }, T1);
22
+ const names = listRosters('ALPHA', T1).map((roster) => roster.name);
23
+ expect(names).toEqual(['Alpha_List']);
24
+ expect(listRosters(undefined, T1).length).toBeGreaterThanOrEqual(2);
25
+ deleteRoster('Alpha_List', T1);
26
+ deleteRoster('beta-list', T1);
27
+ });
28
+
29
+ test('queryRoster 返回命中结果,缺失名单返回 hit=false', () => {
30
+ registerRoster({ name: 't_query', items: ['1.2.3.4'] }, T1);
31
+ expect(queryRoster('t_query', '1.2.3.4', T1)).toEqual({ hit: true, roster: 't_query', value: '1.2.3.4' });
32
+ expect(queryRoster('t_query', '5.6.7.8', T1).hit).toBe(false);
33
+ expect(queryRoster('missing_list', 'x', T1).hit).toBe(false);
34
+ deleteRoster('t_query', T1);
35
+ });
36
+
37
+ test('缺 tenantId 的 scope 直接抛错(fail closed)', () => {
38
+ expect(() => registerRoster({ name: 'x', items: [] }, { tenantId: '' })).toThrow(/tenantId is required/);
39
+ expect(() => getRoster('x', { tenantId: '' })).toThrow(/tenantId is required/);
40
+ });
41
+ });
42
+
43
+ describe('roster 租户隔离与可见性(U5)', () => {
44
+ test('自有 > 租户共享 > 不可见他人;跨租户永不可见', () => {
45
+ registerRoster({ name: 'o_private_a', items: ['x'] }, { tenantId: 't-1', actor: 'user-a' });
46
+ registerRoster({ name: 'o_private_b', items: ['y'] }, { tenantId: 't-1', actor: 'user-b' });
47
+ registerRoster({ name: 'o_shared', items: ['z'] }, { tenantId: 't-1' });
48
+ registerRoster({ name: 'o_other_tenant', items: ['w'] }, { tenantId: 't-2' });
49
+
50
+ // 自有
51
+ expect(getRoster('o_private_a', { tenantId: 't-1', actor: 'user-a' })?.items).toEqual(['x']);
52
+ // 他人私有不可见
53
+ expect(getRoster('o_private_a', { tenantId: 't-1', actor: 'user-b' })).toBeUndefined();
54
+ // 租户共享可见
55
+ expect(getRoster('o_shared', { tenantId: 't-1', actor: 'user-b' })).toBeDefined();
56
+ // 管理员遍历本租户全域
57
+ expect(getRoster('o_private_a', T1)).toBeDefined();
58
+ // 跨租户永不可见
59
+ expect(getRoster('o_private_a', T2)).toBeUndefined();
60
+ expect(getRoster('o_other_tenant', T1)).toBeUndefined();
61
+
62
+ const visibleB = listRosters(undefined, { tenantId: 't-1', actor: 'user-b' }).map((l) => l.name);
63
+ expect(visibleB).toContain('o_private_b');
64
+ expect(visibleB).toContain('o_shared');
65
+ expect(visibleB).not.toContain('o_private_a');
66
+
67
+ deleteRoster('o_private_a', { tenantId: 't-1', actor: 'user-a' });
68
+ deleteRoster('o_private_b', { tenantId: 't-1', actor: 'user-b' });
69
+ deleteRoster('o_shared', T1);
70
+ deleteRoster('o_other_tenant', T2);
71
+ });
72
+
73
+ test('同名遮蔽:自有遮蔽租户共享,删除自有后回落共享', () => {
74
+ registerRoster({ name: 'o_shadow', items: ['shared-item'] }, { tenantId: 't-1' });
75
+ registerRoster({ name: 'o_shadow', items: ['own-item'] }, { tenantId: 't-1', actor: 'user-a' });
76
+
77
+ expect(getRoster('o_shadow', { tenantId: 't-1', actor: 'user-a' })?.items).toEqual(['own-item']);
78
+ expect(getRoster('o_shadow', { tenantId: 't-1', actor: 'user-b' })?.items).toEqual(['shared-item']);
79
+
80
+ deleteRoster('o_shadow', { tenantId: 't-1', actor: 'user-a' });
81
+ expect(getRoster('o_shadow', { tenantId: 't-1', actor: 'user-a' })?.items).toEqual(['shared-item']);
82
+ deleteRoster('o_shadow', T1);
83
+ });
84
+
85
+ test('deleteRoster 权限矩阵:私有仅 owner/本租户管理员, 共享本租户任意 actor', () => {
86
+ registerRoster({ name: 'o_perm_priv', items: [] }, { tenantId: 't-1', actor: 'u1' });
87
+ registerRoster({ name: 'o_perm_shared', items: [] }, { tenantId: 't-1' });
88
+
89
+ expect(deleteRoster('o_perm_priv', { tenantId: 't-1', actor: 'u2' })).toBe(false);
90
+ expect(deleteRoster('o_perm_priv', { tenantId: 't-1', actor: 'u1' })).toBe(true);
91
+ expect(deleteRoster('o_perm_shared', { tenantId: 't-1', actor: 'anyone' })).toBe(true);
92
+
93
+ registerRoster({ name: 'o_perm_priv', items: [] }, { tenantId: 't-1', actor: 'u9' });
94
+ expect(deleteRoster('o_perm_priv', T1)).toBe(true); // 管理员删私有
95
+ });
96
+ });