@@ -30,7 +30,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3030// would close a dependency cycle turbo rejects outright — which is why all 26
3131// of this package's (file, verb) pairs sat in the gate's DEBT ledger until
3232// #5619 sank the two predicates into a package both sides already depend on.
33- import { assertEngineDeleteDispatch , assertEngineUpdateDispatch , assertEngineFindOnePredicate } from '@objectstack/metadata-core' ;
33+ import {
34+ assertEngineDeleteDispatch ,
35+ assertEngineUpdateDispatch ,
36+ assertEngineFindOnePredicate ,
37+ // [#21476] The reason the admin read of a view states — the advisory must carry the same bytes.
38+ anonymousFormIntakeUnavailableMessage ,
39+ } from '@objectstack/metadata-core' ;
3440// [#4716] The advisory-tier rule the Q2 fence test proves its body WOULD trip
3541// — imported from the full barrel deliberately: this is a TEST, not the gate
3642// (the gate itself may only reach the registry through `@objectstack/lint/runtime`,
@@ -39,7 +45,7 @@ import { validateSemanticRoles } from '@objectstack/lint';
3945import { ObjectStackProtocolImplementation } from './protocol.js' ;
4046import type { MetadataAuthoringChannel } from './protocol.js' ;
4147import { SDUI_MANIFEST_SERVICE } from './index.js' ;
42- import { stampHtmlPageRequires } from './runtime-authoring-gate.js' ;
48+ import { PUBLIC_FORM_INTAKE_UNAVAILABLE , stampHtmlPageRequires } from './runtime-authoring-gate.js' ;
4349
4450/** The issue's body. Zod-valid: `approvers[].value` is just a string to the schema. */
4551const brokenApprovalFlow = ( ) => ( {
@@ -1454,3 +1460,131 @@ describe('stored html page `requires` at load and at draft promotion (#20312)',
14541460 expect ( storedPage ( rows ) ?. requires ) . toEqual ( [ 'plugin-absent' ] ) ;
14551461 } ) ;
14561462} ) ;
1463+
1464+ /**
1465+ * [#21476] The public-form intake advisory, end to end through the two write
1466+ * doors: `saveMetaItem` (REST `PUT /meta/view/:name`) and the draft → active
1467+ * promotion `publishMetaItem` (REST `POST /meta/view/:name/publish`).
1468+ *
1469+ * The posture is read off a `tenancy` service in the protocol's own services
1470+ * table — the service, and the reader (`anonymousFormIntakePosture`), the
1471+ * anonymous form doors read. The rows pin both halves: on a walled posture in
1472+ * force the write SUCCEEDS with exactly one warning, located at the form's
1473+ * `sharing` and carrying the admin read's reason byte for byte; every control
1474+ * the doors serve the form on raises nothing, the degraded deployment included
1475+ * — whose REQUESTED posture (`OS_TENANCY_POSTURE`) walls while its in-force
1476+ * posture does not.
1477+ */
1478+ describe ( 'public-form intake advisory on save and publish (#21476)' , ( ) => {
1479+ const SLUG = 'contact-us' ;
1480+ const NAME = 'showcase_inquiry' ;
1481+ /** The showcase's contact form: a container whose public form is `formViews.contact`. */
1482+ const contactContainer = ( ) => ( {
1483+ list : { type : 'grid' , data : { provider : 'object' , object : NAME } , columns : [ { field : 'name' } ] } ,
1484+ formViews : {
1485+ contact : {
1486+ type : 'simple' ,
1487+ data : { provider : 'object' , object : NAME } ,
1488+ sections : [ { name : 'about' , fields : [ { field : 'name' } , { field : 'email' } ] } ] ,
1489+ sharing : { enabled : true , allowAnonymous : true , publicLink : `/forms/${ SLUG } ` } ,
1490+ } ,
1491+ } ,
1492+ } ) ;
1493+ /** The bound object as the live registry holds it — `organization_id` injected at registration. */
1494+ const inquiry = ( tenancyDisabled : boolean ) => ( {
1495+ name : NAME ,
1496+ label : 'Inquiry' ,
1497+ ...( tenancyDisabled ? { tenancy : { enabled : false } } : { } ) ,
1498+ fields : {
1499+ organization_id : { type : 'lookup' , reference : 'sys_organization' } ,
1500+ name : { type : 'text' , label : 'Name' } ,
1501+ email : { type : 'email' , label : 'Email' } ,
1502+ } ,
1503+ } ) ;
1504+
1505+ type Tenancy = 'isolated' | 'group' | 'degraded' | 'single' | 'no-service' ;
1506+ /** A `tenancy` service as plugin-auth registers it: `posture` is the posture IN FORCE. */
1507+ const tenancyService = ( t : Exclude < Tenancy , 'no-service' > ) => ( {
1508+ posture : t === 'degraded' ? 'single' : t ,
1509+ requestedPosture : t === 'degraded' ? 'isolated' : t ,
1510+ defaultOrgId : async ( ) => ( t === 'single' ? 'org_alpha' : null ) ,
1511+ } ) ;
1512+
1513+ function hostOn ( tenancy : Tenancy , tenancyDisabled = false ) {
1514+ const { engine, rows } = makeStubEngine ( ) ;
1515+ engine . registry . listItems = ( type : string ) => ( type === 'object' ? [ inquiry ( tenancyDisabled ) ] : [ ] ) ;
1516+ const services = new Map < string , unknown > (
1517+ tenancy === 'no-service' ? [ ] : [ [ 'tenancy' , tenancyService ( tenancy ) ] ] ,
1518+ ) ;
1519+ const protocol = new ObjectStackProtocolImplementation ( engine , ( ) => services , 'env_test' ) as any ;
1520+ return { protocol, rows } ;
1521+ }
1522+
1523+ const put = ( protocol : any , extra : Record < string , unknown > = { } ) =>
1524+ protocol . saveMetaItem ( { type : 'view' , name : NAME , item : contactContainer ( ) , ...extra } ) ;
1525+ const publish = async ( protocol : any ) => {
1526+ await expect ( put ( protocol , { mode : 'draft' } ) ) . resolves . toMatchObject ( { success : true } ) ;
1527+ return protocol . publishMetaItem ( { type : 'view' , name : NAME } ) ;
1528+ } ;
1529+ const intake = ( response : { advisories ?: Array < { rule : string } > } ) =>
1530+ ( response . advisories ?? [ ] ) . filter ( ( a ) => a . rule === PUBLIC_FORM_INTAKE_UNAVAILABLE ) ;
1531+
1532+ let warn : ReturnType < typeof vi . spyOn > ;
1533+ const savedPosture = process . env . OS_TENANCY_POSTURE ;
1534+ beforeEach ( ( ) => {
1535+ warn = vi . spyOn ( console , 'warn' ) . mockImplementation ( ( ) => { } ) ;
1536+ delete process . env . OS_ALLOW_UNLINTED_METADATA_WRITES ;
1537+ } ) ;
1538+ afterEach ( ( ) => {
1539+ warn . mockRestore ( ) ;
1540+ if ( savedPosture === undefined ) delete process . env . OS_TENANCY_POSTURE ;
1541+ else process . env . OS_TENANCY_POSTURE = savedPosture ;
1542+ } ) ;
1543+
1544+ for ( const posture of [ 'isolated' , 'group' ] as const ) {
1545+ for ( const [ door , write ] of [ [ 'PUT' , put ] , [ 'publish' , publish ] ] as const ) {
1546+ it ( `'${ posture } ' in force, walled object — ${ door } succeeds with exactly one warning, the admin read's reason at the form's sharing` , async ( ) => {
1547+ const { protocol, rows } = hostOn ( posture ) ;
1548+ const response = await write ( protocol ) ;
1549+ expect ( response . success ) . toBe ( true ) ;
1550+ expect ( response . advisories ) . toEqual ( [ {
1551+ severity : 'warning' ,
1552+ rule : PUBLIC_FORM_INTAKE_UNAVAILABLE ,
1553+ where : `view "${ NAME } " · public form "/forms/${ SLUG } "` ,
1554+ path : 'views[0].formViews.contact.sharing' ,
1555+ message : anonymousFormIntakeUnavailableMessage ( SLUG , {
1556+ object : NAME , posture, tenantField : 'organization_id' ,
1557+ } ) ,
1558+ hint : expect . stringContaining ( 'tenancy: { enabled: false }' ) ,
1559+ } ] ) ;
1560+ // Never a refusal: the row landed active.
1561+ expect ( [ ...rows . values ( ) ] . filter ( ( r ) => r . type === 'view' && r . state === 'active' ) ) . toHaveLength ( 1 ) ;
1562+ } ) ;
1563+ }
1564+ }
1565+
1566+ it . each < [ string , Tenancy , boolean ] > ( [
1567+ [ 'walled posture, object declared tenancy: { enabled: false }' , 'isolated' , true ] ,
1568+ [ "the 'single' posture" , 'single' , false ] ,
1569+ [ 'no tenancy service registered' , 'no-service' , false ] ,
1570+ ] ) ( 'CONTROL — %s: PUT and publish raise no intake advisory' , async ( _label , tenancy , tenancyDisabled ) => {
1571+ const { protocol } = hostOn ( tenancy , tenancyDisabled ) ;
1572+ const saved = await put ( protocol ) ;
1573+ expect ( saved . success ) . toBe ( true ) ;
1574+ expect ( intake ( saved ) ) . toEqual ( [ ] ) ;
1575+ const published = await publish ( protocol ) ;
1576+ expect ( published . success ) . toBe ( true ) ;
1577+ expect ( intake ( published ) ) . toEqual ( [ ] ) ;
1578+ } ) ;
1579+
1580+ it ( 'CONTROL — a degraded walled deployment: the REQUESTED posture walls, the posture in force does not; the doors serve, so nothing is raised' , async ( ) => {
1581+ process . env . OS_TENANCY_POSTURE = 'isolated' ;
1582+ const { protocol } = hostOn ( 'degraded' ) ;
1583+ const saved = await put ( protocol ) ;
1584+ expect ( saved . success ) . toBe ( true ) ;
1585+ expect ( intake ( saved ) ) . toEqual ( [ ] ) ;
1586+ const published = await publish ( protocol ) ;
1587+ expect ( published . success ) . toBe ( true ) ;
1588+ expect ( intake ( published ) ) . toEqual ( [ ] ) ;
1589+ } ) ;
1590+ } ) ;
0 commit comments