1717 *
1818 * `?package=all` is the second member of that class. The save door folds it
1919 * to the env-local overlay; the read door forwarded the literal `all`, so its
20- * `version` was resolved at a package address no save writes.
20+ * `version` was resolved at a package address no save writes. The credential
21+ * carry-forward (#8154) is the third: it compared the served body against the
22+ * row at the named key, not the row the draft save overwrites.
2123 *
2224 * Driven at the HTTP door on the real stack: a better-sqlite3 `:memory:`
2325 * engine, the real `sys_metadata*` objects, a real
@@ -38,6 +40,7 @@ import { ObjectQL } from '@objectstack/objectql';
3840import { SqlDriver } from '@objectstack/driver-sql' ;
3941import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol' ;
4042import { SysMetadata , SysMetadataHistoryObject , SysMetadataAuditObject } from '@objectstack/platform-objects/metadata' ;
43+ import { hashSpec } from '@objectstack/metadata-core' ;
4144import { RestServer } from './rest-server.js' ;
4245
4346/** `registry.registerObject` requires a package id (see the sibling real-stack tests). */
@@ -92,8 +95,11 @@ function makeRes() {
9295/**
9396 * Boot the real stack with the cache off: the default cached arm of the plain
9497 * read publishes no OCC carriers, so the active-row pins read the uncached arm.
98+ * `item` names the one item the helpers address; `permissions` the caller's
99+ * system capabilities (a `datasource` is read and written under
100+ * `manage_platform_settings`).
95101 */
96- async function boot ( ) {
102+ async function boot ( opts : { item ?: { type : string ; name : string } ; permissions ?: string [ ] } = { } ) {
97103 const engine = new ObjectQL ( ) ;
98104 liveEngines . push ( engine ) ;
99105 engine . registerDriver ( new SqlDriver ( {
@@ -112,7 +118,8 @@ async function boot() {
112118 const config = { api : { requireAuth : false } , metadata : { enableCache : false } } ;
113119 const rest : any = new RestServer ( createMockServer ( ) as any , protocol as any , config as any ) ;
114120 // An author: `manage_metadata` saves and reads drafts.
115- rest . resolveExecCtx = async ( ) => ( { userId : 'u_author' , systemPermissions : [ 'manage_metadata' ] } ) ;
121+ const systemPermissions = opts . permissions ?? [ 'manage_metadata' ] ;
122+ rest . resolveExecCtx = async ( ) => ( { userId : 'u_author' , systemPermissions } ) ;
116123 rest . registerRoutes ( ) ;
117124
118125 const route = ( method : string , path : string ) => {
@@ -125,8 +132,8 @@ async function boot() {
125132 await route ( method , routePath ) . handler ( { method, headers : { } , query : { } , params : { } , ...req } , res ) ;
126133 return res ;
127134 } ;
128- const item = { type : 'view' , name : 'case_grid' } ;
129- const path = `${ META } /view/case_grid ` ;
135+ const item = opts . item ?? { type : 'view' , name : 'case_grid' } ;
136+ const path = `${ META } /${ item . type } / ${ item . name } ` ;
130137
131138 /** `GET /meta/view/case_grid`. */
132139 const read = ( query : Record < string , string > = { } ) =>
@@ -136,15 +143,31 @@ async function boot() {
136143 call ( 'PUT' , `${ META } /:type/:name` , { path, params : item , query, headers, body } ) ;
137144
138145 /** The stored rows of the item at one lifecycle, with their binding — the store, not the door's word for it. */
139- const stored = async ( state : 'active' | 'draft' ) => {
140- const rows = await engine . find ( 'sys_metadata' , { where : { type : 'view' , name : 'case_grid' , state } } ) ;
146+ const storedBodies = async ( state : 'active' | 'draft' ) => {
147+ const rows = await engine . find ( 'sys_metadata' , { where : { type : item . type , name : item . name , state } } ) ;
141148 return ( rows ?? [ ] ) . map ( ( r : any ) => ( {
142- label : JSON . parse ( String ( r . metadata ) ) . label as string ,
149+ body : JSON . parse ( String ( r . metadata ) ) as Record < string , any > ,
143150 packageId : ( r . package_id ?? null ) as string | null ,
144151 } ) ) ;
145152 } ;
153+ const stored = async ( state : 'active' | 'draft' ) =>
154+ ( await storedBodies ( state ) ) . map ( ( { body, packageId } ) => ( { label : body . label as string , packageId } ) ) ;
155+
156+ /**
157+ * A row as it exists at rest from before the write gates — the only way a
158+ * stored credential is still at rest today, since the save door refuses
159+ * one — written straight to the store, stamped as `put` stamps a row.
160+ */
161+ const seed = async ( state : 'active' | 'draft' , packageId : string | null , body : Record < string , unknown > ) => {
162+ const now = new Date ( ) . toISOString ( ) ;
163+ await engine . insert ( 'sys_metadata' , {
164+ id : `seed_${ state } _${ packageId ?? 'unbound' } ` , type : item . type , name : item . name , organization_id : null ,
165+ package_id : packageId , state, metadata : JSON . stringify ( body ) , checksum : hashSpec ( body , item . type ) ,
166+ version : 1 , created_at : now , updated_at : now ,
167+ } , { context : { isSystem : true } } as any ) ;
168+ } ;
146169
147- return { read, save, stored } ;
170+ return { read, save, stored, storedBodies , seed } ;
148171}
149172
150173const ok = ( res : any ) => {
@@ -267,3 +290,63 @@ describe('[#22128] `?package=all`: the read resolves `all` the way the save does
267290 expect ( await h . stored ( 'draft' ) ) . toEqual ( [ { label : 'draft 2' , packageId : PKG } ] ) ;
268291 } , 60_000 ) ;
269292} ) ;
293+
294+ /**
295+ * [#22128] The credential carry-forward (#8154) compares the served body
296+ * against the row the save OVERWRITES. It read at the key the caller named, so
297+ * a package-less draft save of a package-owned item found neither the
298+ * inherited draft nor the package-bound active row, compared against the code
299+ * layer, and persisted the stored credential away. Measured on the real route
300+ * before the fix: the stored draft's `config.url` no longer held it.
301+ */
302+ describe ( '[#22128] the credential carry-forward reads the row the draft save overwrites' , ( ) => {
303+ /** A fixture value standing in for the userinfo password a legacy row holds. */
304+ const URL_CREDENTIAL = 'fixture-not-a-credential' ;
305+ const DATASOURCE = { type : 'datasource' , name : 'warehouse' } ;
306+ const legacyDatasource = ( label : string ) => ( {
307+ name : 'warehouse' ,
308+ label,
309+ driver : 'postgres' ,
310+ config : {
311+ host : 'db.internal' ,
312+ port : 5432 ,
313+ database : 'warehouse' ,
314+ username : 'reporting' ,
315+ url : `postgresql://reporting:${ URL_CREDENTIAL } @db.internal:5432/warehouse` ,
316+ } ,
317+ } ) ;
318+ const holdsCredential = ( body : Record < string , any > ) => String ( body ?. config ?. url ?? '' ) . includes ( URL_CREDENTIAL ) ;
319+ const bootDatasource = ( ) => boot ( { item : DATASOURCE , permissions : [ 'manage_metadata' , 'manage_platform_settings' ] } ) ;
320+
321+ it ( 'package-owned: the redacted read saved back as a package-less draft, twice, keeps the stored credential' , async ( ) => {
322+ const h = await bootDatasource ( ) ;
323+ await h . seed ( 'active' , PKG , legacyDatasource ( 'live' ) ) ;
324+
325+ const served = ok ( await h . read ( ) ) . item ;
326+ expect ( holdsCredential ( served ) ) . toBe ( false ) ;
327+ ok ( await h . save ( { ...served , label : 'draft 1' } , { mode : 'draft' } ) ) ;
328+ const first = await h . storedBodies ( 'draft' ) ;
329+ expect ( first . map ( ( r ) => r . packageId ) ) . toEqual ( [ PKG ] ) ;
330+ expect ( first . map ( ( r ) => holdsCredential ( r . body ) ) ) . toEqual ( [ true ] ) ;
331+
332+ const servedDraft = ok ( await h . read ( { state : 'draft' } ) ) . item ;
333+ expect ( holdsCredential ( servedDraft ) ) . toBe ( false ) ;
334+ ok ( await h . save ( { ...servedDraft , label : 'draft 2' } , { mode : 'draft' } ) ) ;
335+ const second = await h . storedBodies ( 'draft' ) ;
336+ expect ( second . map ( ( r ) => [ r . body . label , r . packageId , holdsCredential ( r . body ) ] ) ) . toEqual ( [ [ 'draft 2' , PKG , true ] ] ) ;
337+ // The active row is untouched.
338+ expect ( ( await h . storedBodies ( 'active' ) ) . map ( ( r ) => holdsCredential ( r . body ) ) ) . toEqual ( [ true ] ) ;
339+ } , 60_000 ) ;
340+
341+ it ( 'control: env-local, the same round trip keeps the stored credential, as before' , async ( ) => {
342+ const h = await bootDatasource ( ) ;
343+ await h . seed ( 'active' , null , legacyDatasource ( 'live' ) ) ;
344+
345+ const served = ok ( await h . read ( ) ) . item ;
346+ ok ( await h . save ( { ...served , label : 'draft 1' } , { mode : 'draft' } ) ) ;
347+ const servedDraft = ok ( await h . read ( { state : 'draft' } ) ) . item ;
348+ ok ( await h . save ( { ...servedDraft , label : 'draft 2' } , { mode : 'draft' } ) ) ;
349+ const drafts = await h . storedBodies ( 'draft' ) ;
350+ expect ( drafts . map ( ( r ) => [ r . body . label , r . packageId , holdsCredential ( r . body ) ] ) ) . toEqual ( [ [ 'draft 2' , null , true ] ] ) ;
351+ } , 60_000 ) ;
352+ } ) ;
0 commit comments