|
1 | 1 | import { describe, it, expect } from 'vitest'; |
| 2 | +import { z } from 'zod'; |
2 | 3 | import { ActionSchema, ActionParamSchema, Action, type Action as ActionType, ACTION_LOCATIONS, ActionLocationSchema, type ActionLocation } from './action.zod'; |
3 | 4 | import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; |
4 | 5 | import { ObjectSchema } from '../data/object.zod'; |
@@ -252,6 +253,126 @@ describe('requiresFeature lowering', () => { |
252 | 253 | expect(bare.visible).toBeUndefined(); |
253 | 254 | expect(bare).not.toHaveProperty('requiresFeature'); |
254 | 255 | }); |
| 256 | + |
| 257 | + // #5970 gave `visible` a boolean arm, so the lowering now meets two literals |
| 258 | + // it never could before. Boolean algebra decides both, in opposite directions. |
| 259 | + it('treats `visible: true` as the explicit default — lowers to the gate alone', () => { |
| 260 | + const result = ActionSchema.parse({ |
| 261 | + name: 'invite_user', |
| 262 | + label: 'Invite', |
| 263 | + type: 'api', |
| 264 | + target: '/api/v1/auth/organization/invite-member', |
| 265 | + visible: true, |
| 266 | + requiresFeature: 'organization', |
| 267 | + } satisfies z.input<typeof ActionSchema>); |
| 268 | + // Identical to the sugar-only case above: `true && <gate>` IS `<gate>`. |
| 269 | + expect(result.visible).toEqual({ dialect: 'cel', source: 'features.organization != false' }); |
| 270 | + expect(result).not.toHaveProperty('requiresFeature'); |
| 271 | + }); |
| 272 | + |
| 273 | + it('rejects composition with `visible: false` loudly — the gate could never fire (ADR-0078)', () => { |
| 274 | + const result = ActionSchema.safeParse({ |
| 275 | + name: 'invite_user', |
| 276 | + label: 'Invite', |
| 277 | + type: 'api', |
| 278 | + target: '/api/v1/auth/organization/invite-member', |
| 279 | + visible: false, |
| 280 | + requiresFeature: 'organization', |
| 281 | + } satisfies z.input<typeof ActionSchema>); |
| 282 | + expect(result.success).toBe(false); |
| 283 | + if (!result.success) { |
| 284 | + const issue = result.error.issues.find((i) => i.path.includes('requiresFeature')); |
| 285 | + expect(issue).toBeDefined(); |
| 286 | + // The message must name BOTH exits, not just the diagnosis. |
| 287 | + expect(issue!.message).toContain('`visible: false`'); |
| 288 | + expect(issue!.message).toContain('Drop `requiresFeature`'); |
| 289 | + } |
| 290 | + }); |
| 291 | +}); |
| 292 | + |
| 293 | +// ── #5970 — `visible` / `disabled` speak ONE shape ──────────────────────────── |
| 294 | +// Ruled 2026-08-06: both keys are `boolean | string(CEL) | {dialect, source}`. |
| 295 | +// Before this, `visible` had no boolean arm while `disabled` did, so the very |
| 296 | +// common `visible: true` was a spec-side parse error that objectui's `ActionDef` |
| 297 | +// accepted anyway — see the `ActionConditionInputSchema` comment in |
| 298 | +// `action.zod.ts` for why an asymmetry between two keys of the same kind is a |
| 299 | +// dialect nursery rather than a cosmetic gap. |
| 300 | +describe('ActionSchema — visible/disabled unified condition shape', () => { |
| 301 | + const base = { |
| 302 | + name: 'transfer_ownership', |
| 303 | + label: 'Transfer Ownership', |
| 304 | + type: 'api', |
| 305 | + target: '/api/v1/auth/organization/update-member-role', |
| 306 | + } as const satisfies Partial<z.input<typeof ActionSchema>>; |
| 307 | + |
| 308 | + const parse = (key: 'visible' | 'disabled', value: unknown) => |
| 309 | + ActionSchema.safeParse({ ...base, [key]: value }); |
| 310 | + |
| 311 | + for (const key of ['visible', 'disabled'] as const) { |
| 312 | + describe(`\`${key}\``, () => { |
| 313 | + it('accepts the boolean arm and keeps the literal a literal', () => { |
| 314 | + // NOT normalized into `{dialect:'cel', source:'true'}`: a renderer must |
| 315 | + // be able to branch on the constant without standing up an evaluator. |
| 316 | + for (const literal of [true, false]) { |
| 317 | + const result = parse(key, literal); |
| 318 | + expect(result.success, `${key}: ${literal}`).toBe(true); |
| 319 | + if (result.success) expect(result.data[key]).toBe(literal); |
| 320 | + } |
| 321 | + }); |
| 322 | + |
| 323 | + it('accepts the CEL string arm and normalizes it to the envelope', () => { |
| 324 | + const result = parse(key, "record.status == 'open'"); |
| 325 | + expect(result.success).toBe(true); |
| 326 | + if (result.success) { |
| 327 | + expect(result.data[key]).toEqual({ dialect: 'cel', source: "record.status == 'open'" }); |
| 328 | + } |
| 329 | + }); |
| 330 | + |
| 331 | + it('accepts the full envelope arm verbatim, authorship metadata included', () => { |
| 332 | + const envelope = { |
| 333 | + dialect: 'cel' as const, |
| 334 | + source: "record.status == 'open'", |
| 335 | + meta: { rationale: 'only open records can transfer', generatedBy: 'agent:spec' }, |
| 336 | + }; |
| 337 | + const result = parse(key, envelope); |
| 338 | + expect(result.success).toBe(true); |
| 339 | + if (result.success) expect(result.data[key]).toEqual(envelope); |
| 340 | + }); |
| 341 | + |
| 342 | + // The widening must not shrink the rejection surface by one shape. Each |
| 343 | + // of these was rejected before #5970 and is asserted to still be. |
| 344 | + it.each([ |
| 345 | + ['an empty CEL string', ''], |
| 346 | + ['a number', 1], |
| 347 | + ['null', null], |
| 348 | + ['an envelope with neither `source` nor `ast`', { dialect: 'cel' }], |
| 349 | + ['an envelope missing `dialect`', { source: "record.status == 'open'" }], |
| 350 | + ['an envelope with an unknown dialect', { dialect: 'sql', source: 'SELECT 1' }], |
| 351 | + ['a bare empty object', {}], |
| 352 | + ['an array of predicates', ["record.a == 1", "record.b == 2"]], |
| 353 | + ])('still rejects %s', (_label, value) => { |
| 354 | + expect(parse(key, value).success).toBe(false); |
| 355 | + }); |
| 356 | + }); |
| 357 | + } |
| 358 | + |
| 359 | + it('accepts both keys on one action, each on a different arm', () => { |
| 360 | + const result = ActionSchema.parse({ |
| 361 | + ...base, |
| 362 | + visible: true, |
| 363 | + disabled: "record.status == 'closed'", |
| 364 | + } satisfies z.input<typeof ActionSchema>); |
| 365 | + expect(result.visible).toBe(true); |
| 366 | + expect(result.disabled).toEqual({ dialect: 'cel', source: "record.status == 'closed'" }); |
| 367 | + }); |
| 368 | + |
| 369 | + it('reaches the same shape through the registered `action` metadata schema', () => { |
| 370 | + // The authoring door the Studio form and `GET /api/v1/meta` go through — |
| 371 | + // a widening that stopped at the bare export would not reach an author. |
| 372 | + const schema = getMetadataTypeSchema('action'); |
| 373 | + expect(schema, "the 'action' metadata type must resolve to a schema").toBeDefined(); |
| 374 | + expect(schema!.safeParse({ ...base, visible: true, disabled: false }).success).toBe(true); |
| 375 | + }); |
255 | 376 | }); |
256 | 377 |
|
257 | 378 | describe('ActionSchema', () => { |
|
0 commit comments