diff --git a/docs/qa/platform-checklist/areas/records-forms.json b/docs/qa/platform-checklist/areas/records-forms.json index cf9ded73eb3..1216024ac56 100644 --- a/docs/qa/platform-checklist/areas/records-forms.json +++ b/docs/qa/platform-checklist/areas/records-forms.json @@ -4182,7 +4182,7 @@ "title": "Secret/encrypted field: ciphertext at rest, masked on every read, no-op mask re-submit, and fail-CLOSED without an ICryptoProvider (ADR-0100)", "since": "v16", "status": "active", - "revision": 5, + "revision": 6, "priority": "P1", "surface": "mixed", "personas": [ @@ -4254,7 +4254,7 @@ ], "automated": { "kind": "api", - "ref": "packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts (+ field-zoo.matrix.ts masked vectors for f_secret & f_password) — acceptance[0]'s masked-GET half ONLY (its API oracle; knownGaps[2] makes the raw-column read the DB-inspectable extra, and that leg, the 'secret:' ref column and the sys_secret ciphertext, is asserted only by the engine-layer secret-fields.test.ts below), acceptance[1] and negative[0], at the HTTP door: the showcase is booted in-process, POST /data/showcase_field_zoo writes both fields, and GET /data/showcase_field_zoo/ID reads them back as SECRET_MASK, never the written value. packages/objectql/src/secret-fields.test.ts drives a real ObjectQL engine over a stub driver, with no HTTP, so it scores NO api clause of this item: it is engine-layer evidence beside acceptance[0]'s DB-inspectable leg ('encrypts on write: row stores a ref, sys_secret holds ciphertext, no cleartext on the row'), and for acceptance[2] ('update: echoing the read mask back does NOT overwrite the stored secret'), acceptance[3] ('fail-closed: writing a secret field with no CryptoProvider throws'), acceptance[4] (the 'objectql aggregate() rejects credential fields (ADR-0100 / #3171)' describe) and negative[1]–[3]. acceptance[2] and [4] are driven over the API; acceptance[3] is blocked(fixture) on every stock composition (knownGaps[1])" + "ref": "packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts (+ field-zoo.matrix.ts masked vectors for f_secret & f_password) — acceptance[0]'s masked-GET half ONLY, acceptance[1] and negative[0], at the HTTP door (POST then GET /data/showcase_field_zoo/ID reads both fields back as SECRET_MASK). packages/qa/dogfood/test/encrypted-field-behavior.dogfood.test.ts, at the REST data door: on the showcase, acceptance[2] and negative[2] (a PATCH echoing SECRET_MASK leaves the driver-held secret: ref byte-identical and resolveSecretField still answers the original) and acceptance[4] and negative[3] (POST /data/showcase_field_zoo/query grouping by, or taking MIN/MAX of, f_secret or f_password is 400 INVALID_FIELD; a group-by naming no credential column is the 200 control); on a scratch object whose engine is left with no crypto provider (unset once after bootstrap, before any request — the engine state of a boot that never wired one, which no stock composition is, fixtures.knownGaps[1]), acceptance[3] and negative[1] (a non-empty secret POST is refused, 500 INTERNAL_ERROR, the sanitised branch of the engine's plain fail-closed Error, and no row is stored; a password write is 201 and masked on read). packages/objectql/src/secret-fields.test.ts drives a real engine over a stub driver with no HTTP: engine-layer evidence beside acceptance[0]'s DB-inspectable leg, never an api clause's pass" }, "source": [ "packages/objectql/src/secret-fields.ts#SECRET_REF_PREFIX (SECRET_MASK, SECRET_REF_PREFIX, collectSecretFields/collectMaskedReadFields/collectCredentialFields)", @@ -4294,6 +4294,12 @@ "date": "2026-10-26", "change": "automated.ref says the dogfood pin scores acceptance[0]'s masked-GET half only; the 'secret:' ref column and sys_secret ciphertext leg is asserted only in packages/objectql/src/secret-fields.test.ts, at the engine layer (Tier 1 run #25815 (6679644a, pin b41ed7f68956)). No clause changed", "ref": "#25846" + }, + { + "revision": 6, + "date": "2026-10-27", + "change": "automated.ref names the dogfood pin for acceptance[2]–[4] and negative[1]–[3] at the REST data door (#26573 slice 1). acceptance[3] is now pinned on an engine left unwired in-process; on a stock live boot it stays blocked(fixture) as fixtures.knownGaps[1] says, and that note's 'NO live leg' now reads only for a live boot. No clause changed", + "ref": "#26573" } ] }, @@ -5029,7 +5035,7 @@ "title": "A validation rule reads a parent field one hop through a lookup", "since": "v17", "status": "active", - "revision": 4, + "revision": 5, "priority": "P1", "surface": "api", "personas": [ @@ -5088,8 +5094,8 @@ "seed-data-thin" ], "automated": { - "kind": "test", - "ref": "acceptance[3] ONLY. packages/objectql/src/engine-predicate-relationship.test.ts, on a real engine: 'reads the related row under SYSTEM authority' (the related read runs with isSystem true and the acting userId carried), 'ACCEPTS and REFUSES on the real write path, per the parent field', and 'resolves the FK off the PRIOR row when the patch does not carry it'. The other clauses still need the run" + "kind": "dogfood", + "ref": "packages/qa/dogfood/test/predicate-relationship-traversal.dogfood.test.ts, at the REST data door of a booted stack: on the showcase as shipped, acceptance[0] (an invoice on an active account is 2xx and reads back, no unevaluable fault), acceptance[1] and negative[0] (an invoice on a churned account is 400 VALIDATION_FAILED with one _record rule_violation entry carrying the authored message, and nothing is stored), acceptance[2] (a PATCH carrying only the repointed account is refused and the stored account is unchanged) and negative[1] (no readable-parent case answers the unevaluable fault); on a scratch pair, acceptance[3] and negative[2] for the persona no stock showcase persona is (fixtures.knownGaps): a member with create/edit on the child and no grant on the parent, whose parent read is the control (403 PERMISSION_DENIED), writes 2xx against a non-tripping parent and 400 with the authored message against a tripping one. packages/objectql/src/engine-predicate-relationship.test.ts stays engine-layer evidence of the mechanism ('reads the related row under SYSTEM authority' and its two siblings)" }, "source": [ "examples/app-showcase/src/data/objects/invoice.object.ts (no_invoice_for_churned_account)", @@ -5120,6 +5126,12 @@ "date": "2026-10-26", "change": "acceptance[3].verify says its engine-pin route is a declared layer exception, not a rule-6 pass of an HTTP oracle, and the run record names the layer (Tier 1 run #25815 (6679644a, pin b41ed7f68956)). No clause changed", "ref": "#25846" + }, + { + "revision": 5, + "date": "2026-10-27", + "change": "automated.ref names the dogfood pin that scores every clause at the api layer (#26573 slice 1): the showcase invoice rule over REST for acceptance[0]–[2] and negative[0]–[1], and the scratch substitute fixtures.knownGaps describes for acceptance[3] and negative[2], so acceptance[3] no longer rests on the engine layer alone. kind test → dogfood. No clause changed", + "ref": "#26573" } ] }, @@ -5355,7 +5367,7 @@ "title": "Write-time value judgement on v18: picking an option whose stored visibleWhen cannot be evaluated is refused (reason unevaluable), an import with createMissingOptions keeps an unmatched select value while a later PATCH sending it back is refused, an import dry run admits exactly what the write admits, and a malformed element in a multi-valued lookup is refused with its index", "since": "v18", "status": "active", - "revision": 1, + "revision": 2, "priority": "P2", "surface": "api", "personas": [ @@ -5412,7 +5424,7 @@ ], "automated": { "kind": "unit", - "ref": "packages/objectql/src/validation/rule-validator.option-visibility.test.ts + packages/objectql/src/engine-field-predicate-fault.test.ts + packages/objectql/src/import-kept-option-values.test.ts + packages/objectql/src/validation/record-validator.kept-option-values.test.ts + packages/objectql/src/validate-update-stored-row.test.ts + packages/objectql/src/validate-preview-parent.test.ts + packages/objectql/src/engine-multi-reference-element-shape.test.ts" + "ref": "packages/objectql/src/validation/rule-validator.option-visibility.test.ts + packages/objectql/src/engine-field-predicate-fault.test.ts + packages/objectql/src/import-kept-option-values.test.ts + packages/objectql/src/validation/record-validator.kept-option-values.test.ts + packages/objectql/src/validate-update-stored-row.test.ts + packages/objectql/src/validate-preview-parent.test.ts + packages/objectql/src/engine-multi-reference-element-shape.test.ts — acceptance[0]'s pins, each evidence of its own layer (RUNNER rule 6). The api clauses' pin: packages/qa/dogfood/test/write-time-option-and-element-shape.dogfood.test.ts, at the REST doors of a booted scratch stack: acceptance[1] (picking an option whose visibleWhen reads a misspelt key is 400 VALIDATION_FAILED, rule_violation, constraint rule visibleWhen reason unevaluable, nothing stored), acceptance[2] (POST /data/OBJECT/import with createMissingOptions stores the unmatched select value, its dry run reports the same verdict, the option off refuses it invalid_option, and a later PATCH sending it back is 400 invalid_option), acceptance[3] (an update-mode import matched by name: the dry run and the real import agree, admitting a note whose requiredWhen reads the omitted, stored country, and both refusing note required where the merge violates the rule) and acceptance[4] (an object, false or a number in a multiple lookup is 400 invalid_type with constraint.detail 'at index 1: …', nothing stored)" }, "source": [ "packages/objectql/src/validation/rule-validator.ts", @@ -5426,6 +5438,12 @@ "date": "2026-10-16", "change": "initial — authored from the v18 line (18.0.0-next.*) delta: the capability landed on the v18 pre-release line and the ledger carried no `since: \"v18\"` item for it", "ref": "#23879" + }, + { + "revision": 2, + "date": "2026-10-27", + "change": "automated.ref names the dogfood pin that scores acceptance[1]–[4] at the REST data and import doors (#26573 slice 1); the objectql pins stay acceptance[0]'s. No clause changed", + "ref": "#26573" } ] }, @@ -5434,7 +5452,7 @@ "title": "Write semantics changed on v18: a warning-severity validation hit comes back in the 2xx write answer as warnings[], a blocking after* hook abort rolls the whole write back, a `tree` field (with or without an explicit reference) honours its deleteBehavior, and a cascade delete is distinguishable to the child hook", "since": "v18", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "api", "personas": [ @@ -5485,7 +5503,7 @@ ], "automated": { "kind": "unit", - "ref": "packages/objectql/src/validation/advisory-write-answer.test.ts + packages/objectql/src/engine-after-hook-abort-unit.test.ts + packages/objectql/src/engine-cascade-delete.test.ts + packages/objectql/src/engine-cascade-delete-tree.test.ts + packages/verify/src/delete-behavior-referenceless-tree.test.ts — acceptance[0]'s pins. For the api clauses they are evidence of their own layers (RUNNER rule 6): advisory-write-answer.test.ts calls ObjectStackProtocolImplementation createData / updateData in-process, so the REST PATCH body acceptance[1] reads is not observed; engine-after-hook-abort-unit.test.ts drives ObjectQL directly, so no HTTP pin covers the aborting afterInsert (acceptance[2], negative[0])" + "ref": "packages/objectql/src/validation/advisory-write-answer.test.ts + packages/objectql/src/engine-after-hook-abort-unit.test.ts + packages/objectql/src/engine-cascade-delete.test.ts + packages/objectql/src/engine-cascade-delete-tree.test.ts + packages/verify/src/delete-behavior-referenceless-tree.test.ts — acceptance[0]'s pins, each evidence of its own layer for the api clauses (RUNNER rule 6). The api clauses' pins, at the REST door of a booted stack: packages/qa/dogfood/test/write-answer-warnings-rest.dogfood.test.ts for acceptance[1] (on the showcase as shipped, a health jump on showcase_project answers 200 with warnings [{rule: project_health_progression, severity: warning, field: health, code: invalid_transition, message}] and the write is stored; a one-step change and the create answer carry no warnings key); packages/qa/dogfood/test/hook-after-abort-rollback.dogfood.test.ts for acceptance[2] and negative[0] (an aborting afterInsert refuses POST /data/OBJECT, and neither the row nor the hook chain's ctx.api write is stored)" }, "source": [ "packages/objectql/src/engine.ts", @@ -5504,6 +5522,12 @@ "date": "2026-10-26", "change": "automated.ref says what its pins reach at the api clauses' layer (Tier 1 run #25815 (6679644a, pin b41ed7f68956); re-read on main cc378e93): advisory-write-answer.test.ts calls the protocol in-process, so acceptance[1]'s REST PATCH body is unobserved; engine-after-hook-abort-unit.test.ts drives ObjectQL directly, so acceptance[2] and negative[0] have no HTTP pin. The run's third note, acceptance[3] scored on the referenceless rtl_* tree fixture, needs no edit: the clause names no carrier. No clause changed", "ref": "#25846" + }, + { + "revision": 3, + "date": "2026-10-27", + "change": "automated.ref names the HTTP pins for acceptance[1], acceptance[2] and negative[0] (#26573 slice 1): the new write-answer-warnings-rest dogfood file reads the REST PATCH body, and the existing hook-after-abort-rollback dogfood file already refused the aborting create over REST and read the store back, which revision 2 had not named. acceptance[3] is unchanged. No clause changed", + "ref": "#26573" } ] }, @@ -5512,7 +5536,7 @@ "title": "Discussion on v18: reactions are their own records (sys_comment_reaction — 201 with user_id forced, duplicate 409, unreadable comment 403, no update, no deleting another member's), the retired sys_comment.reactions column is refused, a comment edit is platform-stamped, and milestone activity is withheld whole from a reader missing a watched field", "since": "v18", "status": "active", - "revision": 3, + "revision": 4, "priority": "P2", "surface": "api", "personas": [ @@ -5565,7 +5589,7 @@ ], "automated": { "kind": "unit", - "ref": "packages/plugins/plugin-audit/src/objects/sys-comment-reaction.object.test.ts + packages/plugins/plugin-security/src/comment-reaction-own-record-floor.test.ts + packages/plugins/plugin-audit/src/comment-edit-stamp.integration.test.ts + packages/plugins/plugin-audit/src/sys-comment-reactions-door.integration.test.ts (a 'door' test by name only: it boots ObjectKernel + ObjectQLPlugin over sqlite-wasm and maps the thrown error with mapDataError, with no HTTP adapter, so it is engine-layer evidence beside acceptance[1] and [2] and never their pass, RUNNER rule 6) + packages/plugins/plugin-audit/src/activity-field-redaction.test.ts" + "ref": "packages/plugins/plugin-audit/src/objects/sys-comment-reaction.object.test.ts + packages/plugins/plugin-security/src/comment-reaction-own-record-floor.test.ts + packages/plugins/plugin-audit/src/comment-edit-stamp.integration.test.ts + packages/plugins/plugin-audit/src/sys-comment-reactions-door.integration.test.ts + packages/plugins/plugin-audit/src/activity-field-redaction.test.ts — acceptance[0]'s pins, each engine-layer evidence (the 'door' test boots no HTTP adapter, RUNNER rule 6). The api clauses' pin: packages/qa/dogfood/test/comment-reactions-and-edit-stamps.dogfood.test.ts, at the REST data door of an org-bound stack (the comments fixture + plugin-audit, the member armed as org_member and holding a delete-capable set on sys_comment_reaction): acceptance[1] (201 with user_id forced to the caller; duplicate 409 UNIQUE_VIOLATION; a comment the member cannot read 403 RECORD_NOT_ACCESSIBLE; PATCH 405 OBJECT_API_METHOD_NOT_ALLOWED; another member's reaction 403 PERMISSION_DENIED and the row survives, the own delete the control), acceptance[2] (reactions on PATCH and in a $filter 400 INVALID_FIELD, absent from the read) and acceptance[3] (a body edit stores is_edited true and a server edited_at; the client-sent false and 2000-01-01 are dropped)" }, "source": [ "packages/plugins/plugin-audit/src/objects/sys-comment-reaction.object.ts", @@ -5590,6 +5614,12 @@ "date": "2026-10-26", "change": "automated.ref says sys-comment-reactions-door.integration.test.ts drives no HTTP door (Tier 1 run #25815 (6679644a, pin b41ed7f68956); re-read on main cc378e93): it boots ObjectKernel + ObjectQLPlugin over sqlite-wasm and maps errors with mapDataError, so it cannot score the api clauses acceptance[1] and [2]. No clause changed", "ref": "#25846" + }, + { + "revision": 4, + "date": "2026-10-27", + "change": "automated.ref names the dogfood pin that scores acceptance[1]–[3] at the REST data door (#26573 slice 1), with the measured refusals: an update is refused 405 OBJECT_API_METHOD_NOT_ALLOWED (the object offers no update) and a foreign delete 403 PERMISSION_DENIED (the own-record floor). No clause changed", + "ref": "#26573" } ] }, diff --git a/packages/qa/dogfood/test/comment-reactions-and-edit-stamps.dogfood.test.ts b/packages/qa/dogfood/test/comment-reactions-and-edit-stamps.dogfood.test.ts new file mode 100644 index 00000000000..604d79ba67a --- /dev/null +++ b/packages/qa/dogfood/test/comment-reactions-and-edit-stamps.dogfood.test.ts @@ -0,0 +1,192 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Checklist item `records-forms.comment-reactions-and-edit-stamps` (#26573): +// its three api clauses pinned at the REST data door of a booted stack — the +// layer their oracle names. The plugin-audit / plugin-security pins in the +// item's `automated.ref` drive the engine (or an ObjectKernel with no HTTP +// adapter), so they are evidence of their own layer, never these clauses' +// pass (RUNNER rule 6). +// +// acceptance[1] — a reaction answers 201 with `user_id` forced to the +// caller; a duplicate → 409 UNIQUE_VIOLATION; a reaction on a comment the +// caller cannot read → 403 RECORD_NOT_ACCESSIBLE; update and deleting +// another member's reaction are refused. +// acceptance[2] — `sys_comment.reactions` on a write or a filter → 400 +// INVALID_FIELD, and the key is absent from reads. +// acceptance[3] — a body edit sets `is_edited: true` and a server +// `edited_at`; the client-sent values are dropped. +// +// ## The fixture +// +// The comments permission-matrix fixture (`fixtures/comments-fixture.ts`) with +// plugin-audit, org-bound: an org-bound sign-up holds `org_member`, which is +// the position the platform's own-record delete floor (`owner_only_deletes`) +// applies to — org-less, a refused foreign delete would measure nothing +// (`armed.ts`, `principalArmed`). The member also holds `rx_reactor`, the +// delete bit on `sys_comment_reaction` an app ships (the every-member baseline +// carries no delete, ADR-0090 D5), so the foreign-delete refusal is the floor +// and not a missing object bit; deleting the member's own reaction is the +// control that proves it. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { AuditPlugin } from '@objectstack/plugin-audit'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import { commentsFixtureStack, cmtFixtureBaselineSet, commentManagerSet } from './fixtures/comments-fixture.js'; +import { assertArmed, principalArmed } from './armed.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; + +const SYS = { isSystem: true } as const; +const REACTION = 'sys_comment_reaction'; + +const reactorSet = PermissionSetSchema.parse({ + name: 'rx_reactor', + label: 'Reactions fixture — reactor', + objects: { + [REACTION]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, + }, +}); + +const FLOOR = "the platform's own-record DELETE floor (`owner_only_deletes`, positions ['org_member'])"; +const FLOOR_DISARM = + 'an org-less harness: a sign-up holds only `everyone`, the floor never applies, and a refused ' + + "foreign delete would be some other refusal. `orgContext: true` arms it."; + +type Answer = { status: number; json: any }; + +describe('records-forms.comment-reactions-and-edit-stamps — over the REST data door', () => { + let stack: VerifyStack; + let ql: any; + let adminTok = ''; + let memberTok = ''; + let adminId = ''; + let memberId = ''; + let commentId = ''; + let privateCommentId = ''; + + const call = async (token: string, method: string, path: string, body?: unknown): Promise => { + const res = await stack.apiAs(token, method, path, body); + return { status: res.status, json: await res.json().catch(() => ({})) }; + }; + const uid = async (email: string) => String((await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id); + const reactionRows = (where: Record) => ql.find(REACTION, { where, context: SYS }); + + beforeAll(async () => { + stack = await bootStack(commentsFixtureStack as never, { + security: new SecurityPlugin({ + defaultPermissionSets: [...securityDefaultPermissionSets, cmtFixtureBaselineSet, commentManagerSet, reactorSet], + fallbackPermissionSet: cmtFixtureBaselineSet.name, + }), + extraPlugins: [new AuditPlugin()], + orgContext: true, + }); + adminTok = await stack.signIn(); + memberTok = await stack.signUp('rx-member@verify.test'); + ql = await stack.kernel.getServiceAsync('objectql'); + adminId = await uid('admin@objectos.ai'); + memberId = await uid('rx-member@verify.test'); + + expect(await catalogPermissionSet(ql, reactorSet.name), 'fixture set served by the security catalog').toBeTruthy(); + await ql.insert('sys_user_permission_set', { user_id: memberId, permission_set: reactorSet.name }, { context: SYS }); + await assertArmed([ + principalArmed({ + stack, token: memberTok, who: 'the reacting member', positions: ['org_member'], + permissions: [reactorSet.name], control: FLOOR, disarmedBy: FLOOR_DISARM, + }), + ]); + + // A record both read (public_read_write), with one comment by the admin. + const open = await call(adminTok, 'POST', '/data/cmt_open', { name: 'reactions record' }); + expect(open.status, JSON.stringify(open.json)).toBeLessThan(300); + const openId = String(open.json.id ?? open.json.record?.id); + const posted = await call(adminTok, 'POST', '/data/sys_comment', { thread_id: `cmt_open:${openId}`, body: 'admin comment' }); + expect(posted.status, JSON.stringify(posted.json)).toBeLessThan(300); + commentId = String((await ql.findOne('sys_comment', { where: { thread_id: `cmt_open:${openId}` }, context: SYS })).id); + + // A comment on a record the member cannot read (cmt_private, owner-only). + const privateId = String((await ql.insert('cmt_private', { name: 'admin only', owner_id: adminId }, { context: SYS })).id); + const privatePost = await call(adminTok, 'POST', '/data/sys_comment', { thread_id: `cmt_private:${privateId}`, body: 'private comment' }); + expect(privatePost.status, JSON.stringify(privatePost.json)).toBeLessThan(300); + privateCommentId = String((await ql.findOne('sys_comment', { where: { thread_id: `cmt_private:${privateId}` }, context: SYS })).id); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('acceptance[1]: a reaction answers 201 with user_id forced to the caller, and a duplicate is 409 UNIQUE_VIOLATION', async () => { + const created = await call(memberTok, 'POST', `/data/${REACTION}`, { comment_id: commentId, emoji: '👍', user_id: adminId }); + expect(created.status, JSON.stringify(created.json)).toBe(201); + const rows = await reactionRows({ comment_id: commentId, emoji: '👍' }); + expect(rows.map((r: any) => r.user_id)).toEqual([memberId]); + + const again = await call(memberTok, 'POST', `/data/${REACTION}`, { comment_id: commentId, emoji: '👍' }); + expect(again.status, JSON.stringify(again.json)).toBe(409); + expect(again.json.code).toBe('UNIQUE_VIOLATION'); + expect(await reactionRows({ comment_id: commentId, emoji: '👍' })).toHaveLength(1); + }); + + it('acceptance[1]: a reaction on a comment the caller cannot read is 403 RECORD_NOT_ACCESSIBLE, and nothing is stored', async () => { + const denied = await call(memberTok, 'POST', `/data/${REACTION}`, { comment_id: privateCommentId, emoji: '🎉' }); + expect(denied.status, JSON.stringify(denied.json)).toBe(403); + expect(denied.json.code).toBe('RECORD_NOT_ACCESSIBLE'); + expect(await reactionRows({ comment_id: privateCommentId })).toHaveLength(0); + }); + + it('acceptance[1]: a reaction is never updated, and another member\'s reaction is never deleted — the own delete is the control', async () => { + const mine = await call(memberTok, 'POST', `/data/${REACTION}`, { comment_id: commentId, emoji: '🚀' }); + expect(mine.status, JSON.stringify(mine.json)).toBe(201); + const mineId = String((await reactionRows({ comment_id: commentId, emoji: '🚀' }))[0].id); + const theirs = await call(adminTok, 'POST', `/data/${REACTION}`, { comment_id: commentId, emoji: '❤️' }); + expect(theirs.status, JSON.stringify(theirs.json)).toBe(201); + const theirsId = String((await reactionRows({ comment_id: commentId, emoji: '❤️' }))[0].id); + + const patched = await call(memberTok, 'PATCH', `/data/${REACTION}/${mineId}`, { emoji: '🔥' }); + // The object offers no `update` (apiMethods), so the door refuses before any row is read. + expect(patched.status, JSON.stringify(patched.json)).toBe(405); + expect(patched.json.code).toBe('OBJECT_API_METHOD_NOT_ALLOWED'); + expect((await reactionRows({ id: mineId }))[0].emoji).toBe('🚀'); + + // The own-record floor, not a missing bit: the member holds delete on the object (rx_reactor). + const foreign = await call(memberTok, 'DELETE', `/data/${REACTION}/${theirsId}`); + expect(foreign.status, JSON.stringify(foreign.json)).toBe(403); + expect(foreign.json.code).toBe('PERMISSION_DENIED'); + expect(await reactionRows({ id: theirsId })).toHaveLength(1); + + const own = await call(memberTok, 'DELETE', `/data/${REACTION}/${mineId}`); + expect(own.status, JSON.stringify(own.json)).toBeLessThan(300); + expect(await reactionRows({ id: mineId })).toHaveLength(0); + }); + + it('acceptance[2]: sys_comment.reactions on a write or a filter is 400 INVALID_FIELD, and reads carry no such key', async () => { + const write = await call(adminTok, 'PATCH', `/data/sys_comment/${commentId}`, { reactions: [{ emoji: '👍', user_id: adminId }] }); + expect(write.status, JSON.stringify(write.json)).toBe(400); + expect(write.json.code).toBe('INVALID_FIELD'); + + const filter = encodeURIComponent(JSON.stringify(['reactions', '=', 'x'])); + const filtered = await call(adminTok, 'GET', `/data/sys_comment?$filter=${filter}`); + expect(filtered.status, JSON.stringify(filtered.json)).toBe(400); + expect(filtered.json.code).toBe('INVALID_FIELD'); + + const read = await call(adminTok, 'GET', `/data/sys_comment/${commentId}`); + expect(read.status, JSON.stringify(read.json)).toBe(200); + const record = read.json.record ?? read.json; + expect(record.id).toBe(commentId); + expect(Object.keys(record)).not.toContain('reactions'); + }); + + it('acceptance[3]: a body edit is platform-stamped — is_edited true, a server edited_at — and the client-sent values are dropped', async () => { + const before = await ql.findOne('sys_comment', { where: { id: commentId }, context: SYS }); + expect(before.is_edited).toBeFalsy(); + const edited = await call(adminTok, 'PATCH', `/data/sys_comment/${commentId}`, { + body: 'admin comment, edited', is_edited: false, edited_at: '2000-01-01T00:00:00.000Z', + }); + expect(edited.status, JSON.stringify(edited.json)).toBe(200); + const after = await ql.findOne('sys_comment', { where: { id: commentId }, context: SYS }); + expect(after.body).toBe('admin comment, edited'); + expect(Boolean(after.is_edited)).toBe(true); + expect(after.edited_at).toBeTruthy(); + expect(new Date(after.edited_at).getUTCFullYear()).toBeGreaterThan(2000); + }); +}); diff --git a/packages/qa/dogfood/test/encrypted-field-behavior.dogfood.test.ts b/packages/qa/dogfood/test/encrypted-field-behavior.dogfood.test.ts new file mode 100644 index 00000000000..b00189ec01e --- /dev/null +++ b/packages/qa/dogfood/test/encrypted-field-behavior.dogfood.test.ts @@ -0,0 +1,181 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Checklist item `records-forms.encrypted-field-behavior` (#26573): the api +// clauses the field-zoo round-trip does not reach, pinned at the REST data +// door of a booted stack. `packages/objectql/src/secret-fields.test.ts` drives +// a real engine over a stub driver with no HTTP, so it is evidence of its own +// layer and never these clauses' pass (RUNNER rule 6). +// +// acceptance[2], negative[2] — PATCHing `f_secret` back as SECRET_MASK is a +// no-op: the stored ref is the same and it still decrypts to the original. +// acceptance[4], negative[3] — a group-by or MIN/MAX over `f_secret` or +// `f_password` through `POST /data/:object/query` is refused +// (400 INVALID_FIELD), never a value distribution. +// acceptance[3], negative[1] — with NO crypto provider, a non-empty secret +// write is refused and nothing is persisted; a `password` write needs no +// provider. +// +// ## Two boots +// +// The showcase, as shipped, for the field zoo's `f_secret` (Field.secret) and +// `f_password` (Field.password, ackPlaintextMasking). `bootStack` wires one +// LocalCryptoProvider, as `objectstack dev` / `serve` do. +// +// A scratch object for the fail-closed half, on a stack whose engine is left +// with no provider. No stock composition boots that way (the item's +// fixtures.knownGaps[1]) and `bootStack` always wires one, so this boot unsets +// it once after bootstrap — `engine.setCryptoProvider(undefined)`, the same +// field the host's own wiring writes — before any request is made. That is the +// engine state of a boot that never wired one; the door it is driven through +// is the real REST data door. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { SECRET_MASK } from '@objectstack/objectql'; +import { bootShowcase } from './showcase-boot.js'; + +const ZOO = 'showcase_field_zoo'; +const SECRET = 'topsecret-value'; +const PASSWORD = 'p@ssw0rd!'; + +type Answer = { status: number; json: any; text: string }; + +describe('records-forms.encrypted-field-behavior — the showcase field zoo over REST', () => { + let stack: VerifyStack; + let ql: any; + let token = ''; + let projectId = ''; + + const call = async (method: string, path: string, body?: unknown): Promise => { + const res = await stack.apiAs(token, method, path, body); + const text = await res.text(); + let json: any = {}; + try { json = JSON.parse(text); } catch { /* not JSON */ } + return { status: res.status, json, text }; + }; + /** The business row as the driver holds it — the ref, never the plaintext (an engine read masks it). */ + const storedRef = async (id: string) => { + const [row] = await ql.getDriver(ZOO).find(ZOO, { where: { id } }); + return row?.f_secret; + }; + + beforeAll(async () => { + stack = await bootShowcase(); + token = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + const [project] = await stack.rows('showcase_project', {}); + projectId = String(project.id); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('acceptance[2], negative[2]: an echoed mask leaves the stored secret unchanged', async () => { + const created = await call('POST', `/data/${ZOO}`, { name: 'efb-mask', f_master_detail: projectId, f_secret: SECRET }); + expect(created.status, created.text).toBe(201); + expect(created.text).not.toContain(SECRET); + const [row] = await stack.rows(ZOO, { name: 'efb-mask' }); + const id = String(row.id); + const before = await storedRef(id); + expect(String(before)).toMatch(/^secret:/); + expect(await ql.resolveSecretField(ZOO, id, 'f_secret')).toBe(SECRET); + + const patched = await call('PATCH', `/data/${ZOO}/${id}`, { name: 'efb-mask-renamed', f_secret: SECRET_MASK }); + expect(patched.status, patched.text).toBe(200); + const read = await call('GET', `/data/${ZOO}/${id}`); + expect(read.status, read.text).toBe(200); + expect((read.json.record ?? read.json).f_secret).toBe(SECRET_MASK); + expect(read.text).not.toContain(SECRET); + + expect(await storedRef(id)).toBe(before); + expect(await ql.resolveSecretField(ZOO, id, 'f_secret')).toBe(SECRET); + const [renamed] = await stack.rows(ZOO, { id }); + expect(renamed.name).toBe('efb-mask-renamed'); + }); + + it('acceptance[4], negative[3]: a group-by or MIN/MAX over a credential column is refused, not answered', async () => { + const created = await call('POST', `/data/${ZOO}`, { + name: 'efb-agg', f_master_detail: projectId, f_secret: SECRET, f_password: PASSWORD, + }); + expect(created.status, created.text).toBe(201); + const queries: Array<[string, unknown]> = [ + ['group by f_secret', { groupBy: ['f_secret'], aggregations: [{ function: 'count', alias: 'n' }] }], + ['group by f_password', { groupBy: ['f_password'], aggregations: [{ function: 'count', alias: 'n' }] }], + ['max(f_secret)', { aggregations: [{ function: 'max', field: 'f_secret', alias: 'm' }] }], + ['min(f_password)', { aggregations: [{ function: 'min', field: 'f_password', alias: 'm' }] }], + ]; + for (const [label, body] of queries) { + const res = await call('POST', `/data/${ZOO}/query`, body); + expect(res.status, `${label}: ${res.text}`).toBe(400); + expect(res.json.code, `${label}: ${res.text}`).toBe('INVALID_FIELD'); + expect(res.text, label).not.toContain(SECRET); + expect(res.text, label).not.toContain(PASSWORD); + } + // Control: the same door answers a group-by that names no credential column. + const control = await call('POST', `/data/${ZOO}/query`, { groupBy: ['name'], aggregations: [{ function: 'count', alias: 'n' }] }); + expect(control.status, control.text).toBe(200); + }); +}); + +// ── fail-closed: no crypto provider ────────────────────────────────────── + +const VAULT = 'efb_vault'; + +const efbStack = defineStack({ + manifest: { + id: 'com.dogfood.encrypted-field-behavior', namespace: 'efb', version: '0.0.0', type: 'app', + name: 'Encrypted field fail-closed fixture', description: 'A secret and a password field on one object.', + }, + objects: [ + ObjectSchema.create({ + name: VAULT, label: 'Vault', pluralLabel: 'Vaults', sharingModel: 'public_read_write', + fields: { + name: Field.text({ label: 'Name', required: true }), + token: Field.secret({ label: 'Token' }), + pin: Field.password({ label: 'PIN', ackPlaintextMasking: true }), + }, + }), + ], +} as never); + +describe('records-forms.encrypted-field-behavior — fail-closed with no crypto provider', () => { + let stack: VerifyStack; + let token = ''; + + beforeAll(async () => { + stack = await bootStack(efbStack); + const engine = (await stack.kernel.getServiceAsync('objectql')) as { setCryptoProvider(p: unknown): void }; + engine.setCryptoProvider(undefined); + token = await stack.signIn(); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('acceptance[3], negative[1]: a non-empty secret write is refused and nothing is persisted', async () => { + const res = await stack.apiAs(token, 'POST', `/data/${VAULT}`, { name: 'efb-closed', token: 'x-cleartext' }); + const text = await res.text(); + // The engine's fail-closed throw is a plain Error, so REST answers its sanitised + // terminal branch: a refusal, with the message kept server-side. + expect(res.status, text).toBe(500); + expect(JSON.parse(text).code).toBe('INTERNAL_ERROR'); + expect(text).not.toContain('x-cleartext'); + expect(await stack.rows(VAULT, { name: 'efb-closed' })).toHaveLength(0); + const list = await stack.apiAs(token, 'GET', `/data/${VAULT}`); + expect(await list.text()).not.toContain('x-cleartext'); + }); + + it('acceptance[3]: a password write needs no provider — stored, and masked on read', async () => { + const res = await stack.apiAs(token, 'POST', `/data/${VAULT}`, { name: 'efb-pin', pin: '4242-pin' }); + expect(res.status, await res.clone().text()).toBe(201); + const [row] = await stack.rows(VAULT, { name: 'efb-pin' }); + expect(row, 'the password write is stored').toBeTruthy(); + const read = await stack.apiAs(token, 'GET', `/data/${VAULT}/${row.id}`); + const body: any = await read.json(); + expect((body.record ?? body).pin).toBe(SECRET_MASK); + }); +}); diff --git a/packages/qa/dogfood/test/predicate-relationship-traversal.dogfood.test.ts b/packages/qa/dogfood/test/predicate-relationship-traversal.dogfood.test.ts new file mode 100644 index 00000000000..2ea512a737b --- /dev/null +++ b/packages/qa/dogfood/test/predicate-relationship-traversal.dogfood.test.ts @@ -0,0 +1,209 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Checklist item `records-forms.predicate-relationship-traversal` (#26573): a +// validation rule reads a parent field one hop through a lookup — pinned at +// the layer the item's oracle names, the REST data door of a booted stack. +// Its engine pin (`packages/objectql/src/engine-predicate-relationship.test.ts`) +// drives ObjectQL directly and so scores no api clause (RUNNER rule 6). +// +// ## Two boots +// +// - The showcase itself, as shipped: `showcase_invoice` carries +// `no_invoice_for_churned_account` (`record.account.status == 'churned'`), +// and the seed carries churned and non-churned accounts. The admin writes an +// invoice against each (acceptance[0], acceptance[1], negative[0], +// negative[1]) and repoints a stored invoice at a churned account with a +// patch that carries no status (acceptance[2]). +// - A scratch pair for the persona the rule exists to constrain, which no +// stock showcase persona is (the item's fixtures.knownGaps): a member with +// create/edit on the child object and NO grant at all on the lookup's +// parent. The member writes against a non-tripping and a tripping parent +// (acceptance[3], negative[2]); a read of the parent as that member is the +// control that the persona really cannot read it. +// +// The refusal is scored on the envelope — 400 VALIDATION_FAILED, the +// `_record` / `rule_violation` entry — and on the AUTHORED message, which is +// the one place the item names wording as the contract. A refusal that reads +// "could not be evaluated" is the pre-capability fault, and every case below +// asserts it is absent. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { defineStack, P } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { bootShowcase } from './showcase-boot.js'; + +const SYS = { context: { isSystem: true } } as const; +const UNEVALUABLE = 'could not be evaluated'; + +/** The message `no_invoice_for_churned_account` declares (examples/app-showcase invoice.object.ts). */ +const CHURNED_MESSAGE = 'This account has churned — reactivate it before invoicing.'; + +type Answer = { status: number; json: any }; + +const ruleEntries = (json: any) => + ((json?.fields ?? []) as any[]).map((f) => ({ field: f.field, code: f.code, message: f.message })); + +/** The refusal shape acceptance[1] names: one `_record` rule_violation carrying `message`. */ +function expectRuleRefusal(answer: Answer, message: string) { + expect(answer.status, JSON.stringify(answer.json)).toBe(400); + expect(answer.json?.code, JSON.stringify(answer.json)).toBe('VALIDATION_FAILED'); + expect(ruleEntries(answer.json)).toEqual([{ field: '_record', code: 'rule_violation', message }]); + expect(JSON.stringify(answer.json)).not.toContain(UNEVALUABLE); +} + +function expectAdmitted(answer: Answer) { + expect(answer.status, JSON.stringify(answer.json)).toBeLessThan(300); + expect(JSON.stringify(answer.json)).not.toContain(UNEVALUABLE); +} + +const createdId = (json: any): string => String(json?.id ?? json?.record?.id ?? json?.data?.id ?? ''); + +describe('records-forms.predicate-relationship-traversal — the showcase invoice rule over REST', () => { + let stack: VerifyStack; + let token = ''; + let churnedId = ''; + let liveId = ''; + + const call = async (method: string, path: string, body?: unknown): Promise => { + const res = await stack.apiAs(token, method, path, body); + return { status: res.status, json: await res.json().catch(() => ({})) }; + }; + const uniq = (prefix: string) => `${prefix}-${Math.random().toString(36).slice(2, 8)}`; + + beforeAll(async () => { + stack = await bootShowcase(); + token = await stack.signIn(); + const churned = await stack.rows('showcase_account', { status: 'churned' }); + const live = await stack.rows('showcase_account', { status: 'active' }); + expect(churned.length, 'the showcase seed carries a churned account').toBeGreaterThan(0); + expect(live.length, 'the showcase seed carries an active account').toBeGreaterThan(0); + churnedId = String(churned[0].id); + liveId = String(live[0].id); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('acceptance[0]: a write against a non-churned account passes and reads back', async () => { + const name = uniq('prt-live'); + const created = await call('POST', '/data/showcase_invoice', { name, account: liveId }); + expectAdmitted(created); + const id = createdId(created.json); + const read = await call('GET', `/data/showcase_invoice/${id}`); + expect(read.status, JSON.stringify(read.json)).toBe(200); + expect(JSON.stringify(read.json)).toContain(name); + }); + + it('acceptance[1], negative[0]: a write against a churned account is refused with the authored message, and nothing is stored', async () => { + const name = uniq('prt-churned'); + expectRuleRefusal(await call('POST', '/data/showcase_invoice', { name, account: churnedId }), CHURNED_MESSAGE); + expect(await stack.rows('showcase_invoice', { name })).toHaveLength(0); + }); + + it('acceptance[2]: a patch carrying only the repointed account is judged against the account it lands on', async () => { + const name = uniq('prt-repoint'); + const created = await call('POST', '/data/showcase_invoice', { name, account: liveId }); + expectAdmitted(created); + const id = createdId(created.json); + expectRuleRefusal(await call('PATCH', `/data/showcase_invoice/${id}`, { account: churnedId }), CHURNED_MESSAGE); + const [row] = await stack.rows('showcase_invoice', { id }); + expect(row?.account).toBe(liveId); + }); + + it('negative[1]: an unrelated patch on an invoice whose account is readable is not refused by the rule', async () => { + const name = uniq('prt-other'); + const created = await call('POST', '/data/showcase_invoice', { name, account: liveId }); + expectAdmitted(created); + expectAdmitted(await call('PATCH', `/data/showcase_invoice/${createdId(created.json)}`, { owner: 'prt@verify.test' })); + }); +}); + +// ── The persona the rule exists to constrain ─────────────────────────────── + +const PARENT = 'prt_parent'; +const CHILD = 'prt_child'; +const BLOCKED_MESSAGE = 'PRT26573: the parent is blocked'; + +const prtMemberSet = PermissionSetSchema.parse({ + name: 'prt_member', + label: 'Predicate traversal fixture — child writer, no parent grant', + objects: { + [CHILD]: { allowRead: true, allowCreate: true, allowEdit: true }, + }, +}); + +const prtStack = defineStack({ + manifest: { + id: 'com.dogfood.predicate-relationship-traversal', namespace: 'prt', version: '0.0.0', type: 'app', + name: 'Predicate relationship traversal fixture', + description: 'A child whose script rule reads its parent one hop through a lookup.', + }, + objects: [ + ObjectSchema.create({ + name: PARENT, label: 'Parent', pluralLabel: 'Parents', + // Default sharing model (private) and no grant in the member's set. + fields: { name: Field.text({ label: 'Name', required: true }), status: Field.text({ label: 'Status' }) }, + }), + ObjectSchema.create({ + name: CHILD, label: 'Child', pluralLabel: 'Children', sharingModel: 'public_read_write', + fields: { + name: Field.text({ label: 'Name', required: true }), + parent: Field.lookup(PARENT, { label: 'Parent', required: true }), + }, + validations: [ + { type: 'script', name: 'no_child_for_blocked_parent', severity: 'error', + condition: P`record.parent.status == 'blocked'`, message: BLOCKED_MESSAGE }, + ], + } as never), + ], +} as never); + +describe('records-forms.predicate-relationship-traversal — a member who cannot read the parent', () => { + let stack: VerifyStack; + let member = ''; + let openId = ''; + let blockedId = ''; + + const call = async (method: string, path: string, body?: unknown): Promise => { + const res = await stack.apiAs(member, method, path, body); + return { status: res.status, json: await res.json().catch(() => ({})) }; + }; + + beforeAll(async () => { + stack = await bootStack(prtStack, { + security: new SecurityPlugin({ + defaultPermissionSets: [...securityDefaultPermissionSets, prtMemberSet], + fallbackPermissionSet: prtMemberSet.name, + }), + }); + const ql = (await stack.kernel.getServiceAsync('objectql')) as any; + openId = String((await ql.insert(PARENT, { name: 'open parent', status: 'open' }, SYS)).id); + blockedId = String((await ql.insert(PARENT, { name: 'blocked parent', status: 'blocked' }, SYS)).id); + member = await stack.signUp('prt-member@verify.test'); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('control: the member really cannot read the parent', async () => { + const read = await call('GET', `/data/${PARENT}/${openId}`); + expect(read.status, JSON.stringify(read.json)).toBe(403); + expect(read.json.code).toBe('PERMISSION_DENIED'); + }); + + it('acceptance[3]: a legitimate write against a non-tripping parent goes through', async () => { + const created = await call('POST', `/data/${CHILD}`, { name: 'prt-open', parent: openId }); + expectAdmitted(created); + expect(await stack.rows(CHILD, { name: 'prt-open' })).toHaveLength(1); + }); + + it('acceptance[3], negative[2]: a write against a tripping parent is refused with the authored message, never the unevaluable fault', async () => { + expectRuleRefusal(await call('POST', `/data/${CHILD}`, { name: 'prt-blocked', parent: blockedId }), BLOCKED_MESSAGE); + expect(await stack.rows(CHILD, { name: 'prt-blocked' })).toHaveLength(0); + }); +}); diff --git a/packages/qa/dogfood/test/write-answer-warnings-rest.dogfood.test.ts b/packages/qa/dogfood/test/write-answer-warnings-rest.dogfood.test.ts new file mode 100644 index 00000000000..4c392a0c089 --- /dev/null +++ b/packages/qa/dogfood/test/write-answer-warnings-rest.dogfood.test.ts @@ -0,0 +1,74 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Checklist item `records-forms.write-answer-advisories-rollback-and-tree-delete` +// acceptance[1] (#26573): a warning-severity validation hit comes back in the +// 2xx WRITE ANSWER as `warnings[]`, and a write with no hit carries no +// `warnings` key — read off the REST PATCH body, the layer the clause's oracle +// names. `packages/objectql/src/validation/advisory-write-answer.test.ts` calls +// the protocol in-process, so it never observes this body (RUNNER rule 6). +// +// The rule is the showcase's own, as shipped: `showcase_project` declares +// `project_health_progression`, a `severity: 'warning'` state machine on +// `health` (green ↔ yellow ↔ red, one step at a time), on update only. A jump +// green → red is advised, not blocked: the write lands AND the answer says so. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; + +const PROJECT = 'showcase_project'; +const RULE = 'project_health_progression'; +const MESSAGE = 'Health changed by more than one step — confirm this is intentional.'; + +type Answer = { status: number; json: any }; + +describe('records-forms.write-answer-advisories — the REST write answer carries the advisory', () => { + let stack: VerifyStack; + let token = ''; + let accountId = ''; + + const call = async (method: string, path: string, body?: unknown): Promise => { + const res = await stack.apiAs(token, method, path, body); + return { status: res.status, json: await res.json().catch(() => ({})) }; + }; + const createProject = async (): Promise => { + const name = `war-${Math.random().toString(36).slice(2, 8)}`; + const created = await call('POST', `/data/${PROJECT}`, { name, account: accountId, status: 'planned', health: 'green' }); + expect(created.status, JSON.stringify(created.json)).toBe(201); + // No update rule runs on insert, so the create answer is the first no-hit control. + expect(Object.keys(created.json), JSON.stringify(created.json)).not.toContain('warnings'); + const [row] = await stack.rows(PROJECT, { name }); + return String(row.id); + }; + + beforeAll(async () => { + stack = await bootShowcase(); + token = await stack.signIn(); + const [account] = await stack.rows('showcase_account', { status: 'active' }); + accountId = String(account.id); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('a health jump answers 2xx with the hit in warnings[], and the write is stored', async () => { + const id = await createProject(); + const patched = await call('PATCH', `/data/${PROJECT}/${id}`, { health: 'red' }); + expect(patched.status, JSON.stringify(patched.json)).toBe(200); + expect(patched.json.warnings, JSON.stringify(patched.json)).toEqual([ + { rule: RULE, severity: 'warning', field: 'health', code: 'invalid_transition', message: MESSAGE }, + ]); + const [row] = await stack.rows(PROJECT, { id }); + expect(row.health).toBe('red'); + }); + + it('control: a one-step change answers 2xx with no warnings key', async () => { + const id = await createProject(); + const patched = await call('PATCH', `/data/${PROJECT}/${id}`, { health: 'yellow' }); + expect(patched.status, JSON.stringify(patched.json)).toBe(200); + expect(Object.keys(patched.json), JSON.stringify(patched.json)).not.toContain('warnings'); + const [row] = await stack.rows(PROJECT, { id }); + expect(row.health).toBe('yellow'); + }); +}); diff --git a/packages/qa/dogfood/test/write-time-option-and-element-shape.dogfood.test.ts b/packages/qa/dogfood/test/write-time-option-and-element-shape.dogfood.test.ts new file mode 100644 index 00000000000..8ab204d3996 --- /dev/null +++ b/packages/qa/dogfood/test/write-time-option-and-element-shape.dogfood.test.ts @@ -0,0 +1,183 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Checklist item `records-forms.write-time-option-and-element-shape-judgement` +// (#26573): its four api clauses pinned at the REST doors of a booted stack — +// `POST/PATCH /data/:object` and `POST /data/:object/import` — the layer their +// oracle names. The objectql pins in the item's `automated.ref` drive the +// engine or `runImport` over the protocol in-process, so they are evidence of +// their own layer, never these clauses' pass (RUNNER rule 6). +// +// acceptance[1] — picking an option whose `visibleWhen` cannot be evaluated +// → 400 VALIDATION_FAILED, `rule_violation`, `constraint.reason: +// 'unevaluable'`; nothing is stored. +// acceptance[2] — an import with `createMissingOptions` stores an unmatched +// select value and its dry run agrees; a later PATCH sending that value +// back → `invalid_option`. +// acceptance[3] — the dry run of a MATCHED row (update mode) admits exactly +// where the real import admits, and refuses where it refuses. +// acceptance[4] — each malformed element of a multi-valued lookup → 400 +// VALIDATION_FAILED `invalid_type`, naming the element's index. +// +// The fixture is synthetic. The faulting `visibleWhen` reads a misspelt key +// (`record.statsu`); declared in code, it is never judged by the metadata save +// door, which is the shape the item's "stored with +// OS_ALLOW_UNLINTED_METADATA_WRITES=1" step reaches on a live boot. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +const SYS = { context: { isSystem: true } } as const; +const ITEM = 'wto_item'; +const TARGET = 'wto_target'; + +const wtoStack = defineStack({ + manifest: { + id: 'com.dogfood.write-time-option', namespace: 'wto', version: '0.0.0', type: 'app', + name: 'Write-time option and element shape fixture', + description: 'A faulting option gate, a kept-option import, a matched-row dry run and a multi lookup.', + }, + objects: [ + ObjectSchema.create({ + name: TARGET, label: 'Target', pluralLabel: 'Targets', sharingModel: 'public_read_write', + fields: { name: Field.text({ label: 'Name', required: true }) }, + }), + ObjectSchema.create({ + name: ITEM, label: 'Item', pluralLabel: 'Items', sharingModel: 'public_read_write', + fields: { + name: Field.text({ label: 'Name', required: true }), + status: Field.text({ label: 'Status' }), + tier: Field.select({ + label: 'Tier', + options: [ + { label: 'Gold', value: 'gold', visibleWhen: "record.statsu == 'vip'" }, + { label: 'Silver', value: 'silver' }, + ], + }), + priority: Field.select({ + label: 'Priority', + options: [{ label: 'High', value: 'high' }, { label: 'Low', value: 'low' }], + }), + country: Field.select({ + label: 'Country', + options: [{ label: 'China', value: 'cn' }, { label: 'United States', value: 'us' }], + }), + note: Field.text({ label: 'Note', requiredWhen: "record.country == 'us'" }), + refs: Field.lookup(TARGET, { label: 'Refs', multiple: true }), + }, + } as never), + ], +} as never); + +type Answer = { status: number; json: any }; + +describe('records-forms.write-time-option-and-element-shape-judgement — over REST', () => { + let stack: VerifyStack; + let ql: any; + let token = ''; + + const call = async (method: string, path: string, body?: unknown): Promise => { + const res = await stack.apiAs(token, method, path, body); + return { status: res.status, json: await res.json().catch(() => ({})) }; + }; + const importRows = (rows: Record[], extra: Record = {}) => + call('POST', `/data/${ITEM}/import`, { format: 'json', rows, ...extra }); + /** One row's verdict, as the import answer reports it. */ + const verdicts = (json: any) => + ((json?.results ?? []) as any[]).map((r) => ({ ok: r.ok, action: r.action, ...(r.ok ? {} : { field: r.field, code: r.code }) })); + + beforeAll(async () => { + stack = await bootStack(wtoStack); + token = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('acceptance[1]: picking the option whose visibleWhen faults is 400 rule_violation, reason unevaluable, and nothing is stored', async () => { + const res = await call('POST', `/data/${ITEM}`, { name: 'wto-gold', tier: 'gold' }); + expect(res.status, JSON.stringify(res.json)).toBe(400); + expect(res.json.code).toBe('VALIDATION_FAILED'); + expect(res.json.fields, JSON.stringify(res.json)).toEqual([ + expect.objectContaining({ + field: 'tier', code: 'rule_violation', + constraint: expect.objectContaining({ rule: 'visibleWhen', reason: 'unevaluable' }), + }), + ]); + expect(await stack.rows(ITEM, { name: 'wto-gold' })).toHaveLength(0); + // Control: the option with no gate is admitted. + const ok = await call('POST', `/data/${ITEM}`, { name: 'wto-silver', tier: 'silver' }); + expect(ok.status, JSON.stringify(ok.json)).toBe(201); + }); + + it('acceptance[2]: createMissingOptions stores the unmatched value, its dry run agrees, and a later PATCH sending it back is invalid_option', async () => { + const dry = await importRows([{ name: 'wto-kept', priority: 'urgent' }], { createMissingOptions: true, dryRun: true }); + expect(dry.status, JSON.stringify(dry.json)).toBe(200); + expect(await stack.rows(ITEM, { name: 'wto-kept' })).toHaveLength(0); + + const real = await importRows([{ name: 'wto-kept', priority: 'urgent' }], { createMissingOptions: true }); + expect(real.status, JSON.stringify(real.json)).toBe(200); + expect(verdicts(real.json)).toEqual([{ ok: true, action: 'created' }]); + expect(verdicts(dry.json)).toEqual(verdicts(real.json)); + const [stored] = await stack.rows(ITEM, { name: 'wto-kept' }); + expect(stored.priority).toBe('urgent'); + + // Control: with the option off, the same cell is refused. + const off = await importRows([{ name: 'wto-off', priority: 'urgent' }]); + expect(verdicts(off.json), JSON.stringify(off.json)).toEqual([ + { ok: false, action: 'failed', field: 'priority', code: 'invalid_option' }, + ]); + + const patched = await call('PATCH', `/data/${ITEM}/${stored.id}`, { priority: 'urgent' }); + expect(patched.status, JSON.stringify(patched.json)).toBe(400); + expect(patched.json.code).toBe('VALIDATION_FAILED'); + expect((patched.json.fields ?? []).map((f: any) => [f.field, f.code])).toEqual([['priority', 'invalid_option']]); + // The row stays editable: a patch that leaves the field out is admitted. + const other = await call('PATCH', `/data/${ITEM}/${stored.id}`, { status: 'touched' }); + expect(other.status, JSON.stringify(other.json)).toBe(200); + }); + + it('acceptance[3]: the dry run of a matched row admits where the import admits, and refuses where it refuses', async () => { + await ql.insert(ITEM, { name: 'wto-matched', country: 'cn' }, SYS); + const update = { writeMode: 'update', matchFields: ['name'] }; + + // `note`'s requiredWhen reads `country`, which the row omits: judged against the stored row. + const admitDry = await importRows([{ name: 'wto-matched', note: 'x' }], { ...update, dryRun: true }); + expect(verdicts(admitDry.json), JSON.stringify(admitDry.json)).toEqual([{ ok: true, action: 'updated' }]); + const [untouched] = await stack.rows(ITEM, { name: 'wto-matched' }); + expect(untouched.note ?? null).toBeNull(); + const admitReal = await importRows([{ name: 'wto-matched', note: 'x' }], update); + expect(verdicts(admitReal.json)).toEqual(verdicts(admitDry.json)); + + // Control: a matched row whose merge really violates the rule is refused by both. + await ql.insert(ITEM, { name: 'wto-matched-2', country: 'cn' }, SYS); + const refuseDry = await importRows([{ name: 'wto-matched-2', country: 'us' }], { ...update, dryRun: true }); + expect(verdicts(refuseDry.json), JSON.stringify(refuseDry.json)).toEqual([ + { ok: false, action: 'failed', field: 'note', code: 'required' }, + ]); + const refuseReal = await importRows([{ name: 'wto-matched-2', country: 'us' }], update); + expect(verdicts(refuseReal.json)).toEqual(verdicts(refuseDry.json)); + const [kept] = await stack.rows(ITEM, { name: 'wto-matched-2' }); + expect(kept.country).toBe('cn'); + }); + + it('acceptance[4]: each malformed multi-lookup element is 400 invalid_type naming its index', async () => { + const target = await ql.insert(TARGET, { name: 'wto-target' }, SYS); + for (const [label, bad] of [['an object', { id: target.id }], ['false', false], ['a number', 42]] as const) { + const name = `wto-refs-${label.replace(/\s/g, '-')}`; + const res = await call('POST', `/data/${ITEM}`, { name, refs: [target.id, bad] }); + expect(res.status, `${label}: ${JSON.stringify(res.json)}`).toBe(400); + expect(res.json.code).toBe('VALIDATION_FAILED'); + const [entry] = res.json.fields ?? []; + expect(entry, `${label}: ${JSON.stringify(res.json)}`).toMatchObject({ field: 'refs', code: 'invalid_type' }); + expect(String(entry.constraint?.detail), `${label}: the refusal names the index`).toMatch(/^at index 1:/); + expect(await stack.rows(ITEM, { name })).toHaveLength(0); + } + // Control: well-formed ids are stored. + const ok = await call('POST', `/data/${ITEM}`, { name: 'wto-refs-ok', refs: [target.id] }); + expect(ok.status, JSON.stringify(ok.json)).toBe(201); + }); +});