|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// [#24468] A validation rule that reads, through a lookup, a field the |
| 4 | +// lookup's target does not declare — on a real boot: the real `ObjectQL`, SQL |
| 5 | +// driver, REST, auth and metadata layers. |
| 6 | +// |
| 7 | +// ## The two halves |
| 8 | +// |
| 9 | +// - The save door. `PUT /api/v1/meta/object/:name` refuses a rule condition |
| 10 | +// reading `record.account.region` when `rrh_account` declares no `region`, |
| 11 | +// under the existing `EXPRESSION_INVALID` rule, naming the field and the |
| 12 | +// target object. The same object with only the rule reading a declared field |
| 13 | +// saves. |
| 14 | +// - The write. A rule already stored with such a read (here: declared in code, |
| 15 | +// which this door never judged) refuses every write, naming its own field; |
| 16 | +// a VALID sibling rule reading through the SAME lookup keeps the verdict it |
| 17 | +// gives alone — it admits an order on an open account and refuses one on a |
| 18 | +// closed account with its own message. |
| 19 | +// |
| 20 | +// Fixtures are synthetic. ⚠️ No test title states a value. |
| 21 | + |
| 22 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 23 | +import { bootStack, type VerifyStack } from '@objectstack/verify'; |
| 24 | +import { defineStack } from '@objectstack/spec'; |
| 25 | +import { ObjectSchema, Field } from '@objectstack/spec/data'; |
| 26 | + |
| 27 | +const SYS = { context: { isSystem: true } } as const; |
| 28 | +/** The valid rule: an order needs an account that is not closed. */ |
| 29 | +const VALID = "record.account.status == 'closed'"; |
| 30 | +const VALID_MESSAGE = 'RRHVALID81: the account is closed'; |
| 31 | +/** Reads a field `rrh_account` does not declare. */ |
| 32 | +const UNDECLARED = "record.account.region == 'north'"; |
| 33 | + |
| 34 | +const object = (name: string, fields: Record<string, unknown>, validations: unknown[]) => |
| 35 | + ObjectSchema.create({ name, label: name, pluralLabel: name, sharingModel: 'public_read_write', fields, validations } as never); |
| 36 | + |
| 37 | +const orderFields = () => ({ |
| 38 | + label: Field.text({ label: 'Label' }), |
| 39 | + account: Field.lookup('rrh_account', { label: 'Account' }), |
| 40 | +}); |
| 41 | +const validRule = { name: 'open_account', type: 'script', severity: 'error', message: VALID_MESSAGE, condition: VALID }; |
| 42 | +const badRule = { name: 'north_only', type: 'script', severity: 'error', message: 'RRHBAD82', condition: UNDECLARED }; |
| 43 | + |
| 44 | +const fixtureStack = defineStack({ |
| 45 | + manifest: { |
| 46 | + id: 'com.dogfood.rule-hop-undeclared', namespace: 'rrh', version: '0.0.0', type: 'app', |
| 47 | + name: 'Rule Hop Undeclared Field Fixture', |
| 48 | + description: 'Orders whose validation rules read through a lookup to an account.', |
| 49 | + }, |
| 50 | + objects: [ |
| 51 | + object('rrh_account', { status: Field.text({ label: 'Status' }) }, []), |
| 52 | + // The stored bad rule: declared in code, beside the valid sibling. |
| 53 | + object('rrh_order', orderFields(), [validRule, badRule]), |
| 54 | + // Control: the valid rule alone. |
| 55 | + object('rrh_order_alone', orderFields(), [validRule]), |
| 56 | + ], |
| 57 | +}); |
| 58 | + |
| 59 | +/** The object body a `PUT /meta/object/:name` writes: plain JSON, as Studio or an AI author sends it. */ |
| 60 | +const saveBody = (name: string, validations: unknown[]) => ({ |
| 61 | + name, |
| 62 | + label: name, |
| 63 | + pluralLabel: name, |
| 64 | + sharingModel: 'public_read_write', |
| 65 | + fields: { |
| 66 | + label: { type: 'text', label: 'Label' }, |
| 67 | + account: { type: 'lookup', label: 'Account', reference: 'rrh_account' }, |
| 68 | + }, |
| 69 | + validations, |
| 70 | +}); |
| 71 | + |
| 72 | +type Row = Record<string, any>; |
| 73 | + |
| 74 | +describe('[#24468] a validation rule reading a field its lookup target does not declare', () => { |
| 75 | + let stack: VerifyStack; |
| 76 | + let token = ''; |
| 77 | + let openId = ''; |
| 78 | + let closedId = ''; |
| 79 | + |
| 80 | + const call = async (method: string, path: string, body?: unknown) => { |
| 81 | + const res = await stack.apiAs(token, method, path, body); |
| 82 | + const json: any = await res.json().catch(() => ({})); |
| 83 | + return { status: res.status, json }; |
| 84 | + }; |
| 85 | + /** Every refusal text the write's response carries, joined. */ |
| 86 | + const refusalText = (json: any): string => JSON.stringify(json ?? {}); |
| 87 | + |
| 88 | + beforeAll(async () => { |
| 89 | + stack = await bootStack(fixtureStack as unknown as Parameters<typeof bootStack>[0]); |
| 90 | + const ql = (await stack.kernel.getServiceAsync('objectql')) as any; |
| 91 | + token = await stack.signIn(); |
| 92 | + openId = String((await ql.insert('rrh_account', { status: 'open' }, SYS)).id); |
| 93 | + closedId = String((await ql.insert('rrh_account', { status: 'closed' }, SYS)).id); |
| 94 | + }, 180_000); |
| 95 | + |
| 96 | + afterAll(async () => { |
| 97 | + await stack?.stop(); |
| 98 | + }); |
| 99 | + |
| 100 | + it('the save door refuses the rule, naming the field and the target object', async () => { |
| 101 | + const saved = await call('PUT', '/meta/object/rrh_order_saved', saveBody('rrh_order_saved', [validRule, badRule])); |
| 102 | + expect(saved.status, JSON.stringify(saved.json)).toBe(422); |
| 103 | + expect(saved.json?.code, JSON.stringify(saved.json)).toBe('INVALID_METADATA'); |
| 104 | + const issues = (saved.json?.issues ?? []) as Row[]; |
| 105 | + // The existing `EXPRESSION_INVALID` rule, located at the rule the author edits. |
| 106 | + expect(issues.map((i) => [i.rule, i.where]), JSON.stringify(saved.json)).toEqual([ |
| 107 | + ['expression-invalid', "object 'rrh_order_saved' · validation 'north_only'"], |
| 108 | + ]); |
| 109 | + expect(String(issues[0].message)).toMatch(/^unknown field `region` on `rrh_account`/); |
| 110 | + }); |
| 111 | + |
| 112 | + it('control: the same object with only the rule reading a declared field saves', async () => { |
| 113 | + const saved = await call('PUT', '/meta/object/rrh_order_ok', saveBody('rrh_order_ok', [validRule])); |
| 114 | + expect(saved.status, JSON.stringify(saved.json)).toBe(200); |
| 115 | + }); |
| 116 | + |
| 117 | + it('control: the valid rule alone admits an open account and refuses a closed one', async () => { |
| 118 | + const open = await call('POST', '/data/rrh_order_alone', { label: 'open', account: openId }); |
| 119 | + expect(open.status, JSON.stringify(open.json)).toBe(201); |
| 120 | + const closed = await call('POST', '/data/rrh_order_alone', { label: 'closed', account: closedId }); |
| 121 | + expect(closed.status, JSON.stringify(closed.json)).toBe(400); |
| 122 | + expect(refusalText(closed.json)).toContain(VALID_MESSAGE); |
| 123 | + }); |
| 124 | + |
| 125 | + it('beside a stored bad rule, the valid rule keeps its own verdict and the bad one names only its own field', async () => { |
| 126 | + // An open account: the valid rule admits it; only the bad rule refuses, |
| 127 | + // and the refusal names `region`, never the valid rule. |
| 128 | + const open = await call('POST', '/data/rrh_order', { label: 'open', account: openId }); |
| 129 | + expect(open.status, JSON.stringify(open.json)).toBe(400); |
| 130 | + const openText = refusalText(open.json); |
| 131 | + expect(openText).toContain("Validation rule 'north_only' could not be evaluated"); |
| 132 | + expect(openText).toContain("declares no 'region'"); |
| 133 | + expect(openText).not.toContain("Validation rule 'open_account'"); |
| 134 | + expect(openText).not.toContain(VALID_MESSAGE); |
| 135 | + |
| 136 | + // A closed account: the valid rule refuses with its OWN message, as alone. |
| 137 | + const closed = await call('POST', '/data/rrh_order', { label: 'closed', account: closedId }); |
| 138 | + expect(closed.status, JSON.stringify(closed.json)).toBe(400); |
| 139 | + const closedText = refusalText(closed.json); |
| 140 | + expect(closedText).toContain(VALID_MESSAGE); |
| 141 | + expect(closedText).not.toContain("Validation rule 'open_account' could not be evaluated"); |
| 142 | + }); |
| 143 | +}); |
0 commit comments