@@ -95,11 +95,11 @@ function setup(perms: string[]) {
9595 return { rest, protocol } ;
9696}
9797
98- async function getList ( rest : any , type = 'app' ) {
98+ async function getList ( rest : any , type = 'app' , query : Record < string , unknown > = { } ) {
9999 const route = rest . getRoutes ( ) . find ( ( r : any ) => r . method === 'GET' && r . path === '/api/v1/meta/:type' ) ;
100100 if ( ! route ) throw new Error ( 'meta/:type route not registered' ) ;
101101 const res = makeRes ( ) ;
102- await route . handler ( { method : 'GET' , params : { type } , query : { } , body : { } , headers : { } } , res ) ;
102+ await route . handler ( { method : 'GET' , params : { type } , query, body : { } , headers : { } } , res ) ;
103103 return res ;
104104}
105105
@@ -183,3 +183,148 @@ describe('#4829 — `GET /meta/app` gates on `_unpublished`, never on `hidden`',
183183 expect ( allowed . body ?. item ?. name ) . toBe ( 'production_management' ) ;
184184 } ) ;
185185} ) ;
186+
187+ // ── #7566 ───────────────────────────────────────────────────────────────────
188+ //
189+ // `GET /api/v1/meta/app?id=…` accepted the parameter and then dropped it: the
190+ // SAME apps came back for every value, including one that names no app. The
191+ // acceptance criterion is therefore a BODY fact, not a status code — a route
192+ // that 200s either way is exactly what the reporter saw. Every case below
193+ // asserts the app names in the response.
194+ //
195+ // The defect's cost is that a caller cannot tell a working filter from a
196+ // dropped one: a client that asks for one app and renders `items[0]` gets a
197+ // plausible, wrong answer, and a bogus id can never come back empty.
198+ //
199+ // This file already owns `GET /meta/app`'s list body (the #4829 gate above),
200+ // and the filter has to COMPOSE with that gate rather than sit beside it — an
201+ // `?id=` naming an unpublished app must still be withheld from a non-builder —
202+ // so the cases live here with the fixture that has an unpublished app in it.
203+
204+ describe ( '#7566 — `GET /meta/app?id=` narrows the list instead of being dropped' , ( ) => {
205+ it ( 'a MATCHING id returns exactly that app' , async ( ) => {
206+ const { rest } = setup ( [ 'manage_users' ] ) ;
207+ const res = await getList ( rest , 'app' , { id : 'crm' } ) ;
208+
209+ expect ( res . statusCode ) . toBe ( 200 ) ;
210+ expect ( namesFrom ( res . body ) ) . toEqual ( [ 'crm' ] ) ;
211+ // The stated failure mode: the unasked-for apps are gone. Before this
212+ // change the assertion below is what failed — `account` came back too.
213+ expect ( namesFrom ( res . body ) ) . not . toContain ( 'account' ) ;
214+ } ) ;
215+
216+ it ( 'a NON-MATCHING id returns an empty list — a 200, not a 404, and not every app' , async ( ) => {
217+ const { rest } = setup ( [ 'manage_users' ] ) ;
218+ const res = await getList ( rest , 'app' , { id : 'no_such_app' } ) ;
219+
220+ // Empty-vs-404 is measured off this route's siblings, not chosen: the
221+ // list route serves an empty list for `?package=<no such package>` and
222+ // for `/meta/view?object=<no such object>`, and the only 404 on the meta
223+ // surface is the single-item address `GET /meta/:type/:name` (pinned
224+ // above). A list filter that matched nothing is a list of nothing.
225+ expect ( res . statusCode ) . toBe ( 200 ) ;
226+ expect ( namesFrom ( res . body ) ) . toEqual ( [ ] ) ;
227+ expect ( res . body ?. error ) . toBeUndefined ( ) ;
228+ } ) ;
229+
230+ it ( 'an ABSENT id still returns the whole published set (the preservation half)' , async ( ) => {
231+ const { rest } = setup ( [ 'manage_users' ] ) ;
232+
233+ expect ( namesFrom ( ( await getList ( rest , 'app' ) ) . body ) . sort ( ) ) . toEqual ( [ 'account' , 'crm' ] ) ;
234+ // `?id=` (empty) is the "no filter" spelling an unset `<select>` submits
235+ // — the same falsy gate `?package=` on this route has always used. It
236+ // must not become a new 400 or an empty list.
237+ expect ( namesFrom ( ( await getList ( rest , 'app' , { id : '' } ) ) . body ) . sort ( ) )
238+ . toEqual ( [ 'account' , 'crm' ] ) ;
239+ } ) ;
240+
241+ it ( 'a MALFORMED id — supplied twice — is refused with 400, not silently resolved' , async ( ) => {
242+ const { rest } = setup ( [ 'manage_users' ] ) ;
243+ const res = await getList ( rest , 'app' , { id : [ 'crm' , 'account' ] } ) ;
244+
245+ // Two conflicting intents in one well-formed request. Picking one is a
246+ // wrong answer delivered as a success, and `String(['crm','account'])`
247+ // would have made it the single app name `'crm,account'` — a name no app
248+ // has, so the filter would silently empty. ADR-0112 nested envelope with
249+ // the standard catalog's 400 member, the same answer this route already
250+ // gives for a repeated `?package=` / `?object=` / `?include=` (#6877).
251+ expect ( res . statusCode ) . toBe ( 400 ) ;
252+ expect ( res . body ?. error ?. code ) . toBe ( 'VALIDATION_ERROR' ) ;
253+ expect ( res . body ?. error ?. message ) . toContain ( '"id"' ) ;
254+ // Refused means refused: no app list rode along with the error.
255+ expect ( res . body ?. items ) . toBeUndefined ( ) ;
256+ expect ( Array . isArray ( res . body ) ) . toBe ( false ) ;
257+ } ) ;
258+
259+ it ( 'a one-element array is ONE occurrence and still filters' , async ( ) => {
260+ // `?id=crm` reaches some adapters as `['crm']`; that is one occurrence
261+ // encoded differently, not a repetition, so it must narrow rather than
262+ // 400 — and it must not survive as an array into the comparison, where
263+ // `['crm'] === 'crm'` is false and the filter would empty.
264+ const { rest } = setup ( [ 'manage_users' ] ) ;
265+ const res = await getList ( rest , 'app' , { id : [ 'crm' ] } ) ;
266+
267+ expect ( res . statusCode ) . toBe ( 200 ) ;
268+ expect ( namesFrom ( res . body ) ) . toEqual ( [ 'crm' ] ) ;
269+ } ) ;
270+
271+ it ( 'the PLURAL spelling filters identically — `/meta/apps?id=`' , async ( ) => {
272+ // Prime Directive #3 makes plural the canonical REST spelling, and every
273+ // other per-type filter on this handler keys off the singular through
274+ // `metaTypeSingular`. A filter that only ran on `/meta/app` would be the
275+ // #6238-class spelling hole one parameter over.
276+ const { rest } = setup ( [ 'manage_users' ] ) ;
277+
278+ expect ( namesFrom ( ( await getList ( rest , 'apps' , { id : 'crm' } ) ) . body ) ) . toEqual ( [ 'crm' ] ) ;
279+ expect ( namesFrom ( ( await getList ( rest , 'apps' , { id : 'no_such_app' } ) ) . body ) ) . toEqual ( [ ] ) ;
280+ } ) ;
281+
282+ it ( 'composes WITH the publish gate — `?id=<unpublished>` stays withheld from a non-builder' , async ( ) => {
283+ // The filter narrows within what the caller may observe, never around
284+ // it. ADR-0045 §3 says an unpublished app is externally unobservable, so
285+ // naming it must answer the same empty list as naming a nonexistent one
286+ // — the two are indistinguishable to a non-builder by design.
287+ const denied = await getList ( setup ( [ 'manage_users' ] ) . rest , 'app' , { id : 'production_management' } ) ;
288+ expect ( denied . statusCode ) . toBe ( 200 ) ;
289+ expect ( namesFrom ( denied . body ) ) . toEqual ( [ ] ) ;
290+ expect ( JSON . stringify ( denied . body ) ) . not . toContain ( 'secret_production_line' ) ;
291+
292+ // …and a builder, who may observe it, gets it — narrowed to just it.
293+ const allowed = await getList ( setup ( [ 'studio.access' ] ) . rest , 'app' , { id : 'production_management' } ) ;
294+ expect ( namesFrom ( allowed . body ) ) . toEqual ( [ 'production_management' ] ) ;
295+ } ) ;
296+
297+ it ( 'does not depend on what the caller holds — a caller with NO permissions filters too' , async ( ) => {
298+ // The permission filter above lives in a branch of its own, guarded by
299+ // a resolved `ctx?.userId`. The `id` filter is deliberately NOT inside
300+ // that branch: narrowing to the app you named is not a privilege, and a
301+ // caller holding nothing asked the same question as an admin.
302+ //
303+ // (An anonymous caller is not the case to state this with: the
304+ // anonymous-deny gate refuses `GET /meta/:type` with 401 before the
305+ // handler body runs at all, unconditionally since #3963 — measured, not
306+ // assumed. The least-privileged caller who reaches the filter is this
307+ // one.)
308+ const { rest } = setup ( [ ] ) ;
309+
310+ expect ( namesFrom ( ( await getList ( rest , 'app' , { id : 'account' } ) ) . body ) ) . toEqual ( [ 'account' ] ) ;
311+ expect ( namesFrom ( ( await getList ( rest , 'app' , { id : 'no_such_app' } ) ) . body ) ) . toEqual ( [ ] ) ;
312+ // Unfiltered, the same caller still receives everything the gate lets
313+ // through — the filter is what changed, not the gate.
314+ expect ( namesFrom ( ( await getList ( rest , 'app' ) ) . body ) . length ) . toBeGreaterThan ( 1 ) ;
315+ } ) ;
316+
317+ it ( 'is scoped to `app` — another type\'s list is not narrowed by `?id=`' , async ( ) => {
318+ // Deliberately not generalised: #7566 is filed on the app list, and
319+ // teaching every meta type an `id` filter in the same change would be
320+ // surface expansion with nothing measured behind it. `?id=` on another
321+ // type keeps being ignored exactly as before.
322+ const { rest, protocol } = setup ( [ 'manage_users' ] ) ;
323+ protocol . getMetaItems = vi . fn ( async ( { type } : any ) =>
324+ String ( type ?? '' ) === 'view' ? [ { name : 'all_leads' } , { name : 'my_leads' } ] : [ ] ) ;
325+
326+ const res = await getList ( rest , 'view' , { id : 'all_leads' } ) ;
327+ expect ( res . statusCode ) . toBe ( 200 ) ;
328+ expect ( namesFrom ( res . body ) ) . toEqual ( [ 'all_leads' , 'my_leads' ] ) ;
329+ } ) ;
330+ } ) ;
0 commit comments