|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// A caller-scoped list view is never an object's FIRST, and every entry that |
| 4 | +// wants one names it (#21972 — the family #21960 opened with Setup → Users). |
| 5 | +// |
| 6 | +// The mechanism: when a route names no view, the console opens the object's |
| 7 | +// first declared list view (objectui `ObjectView`: the URL view id, then |
| 8 | +// `?view=`, then a view marked `isDefault`, then `views[0]`; these objects mark |
| 9 | +// none). So a `{current_user_id}`-filtered view declared first is what an |
| 10 | +// administrator lands on from a Setup entry that names no view — their own |
| 11 | +// rows, on the page meant for administering everyone's — and what every |
| 12 | +// bare-object door opens (the record page's object breadcrumb, the object |
| 13 | +// switcher). |
| 14 | +// |
| 15 | +// Both pins are DERIVED from this package's navigation, never from a hand list |
| 16 | +// of objects: the population is every `type: 'object'` entry of |
| 17 | +// `SETUP_NAV_CONTRIBUTIONS` and of `ACCOUNT_APP`, and the object each names, |
| 18 | +// resolved from this package's own exports. |
| 19 | +// |
| 20 | +// (a) every object such an entry names declares a first list view that is |
| 21 | +// not caller-scoped; |
| 22 | +// (b) every such entry whose object declares a caller-scoped view names a |
| 23 | +// view (`viewName`) the object declares under that name — and a Setup |
| 24 | +// entry names one that is not caller-scoped. |
| 25 | +// |
| 26 | +// "Caller-scoped" is read off the view itself: `{current_user_id}` anywhere in |
| 27 | +// it (the `${current_user_id}` spelling contains it too). That token is |
| 28 | +// presentation scope, not access: which rows a caller may read is RLS's |
| 29 | +// decision, so the declared order decides which view opens and nothing else. |
| 30 | +// |
| 31 | +// Reach, stated so a green run is not read wider than it is: |
| 32 | +// - Setup entries a PLUGIN contributes at runtime (`nav_record_shares` from |
| 33 | +// `@objectstack/plugin-sharing`, `nav_approval_requests` from |
| 34 | +// `@objectstack/plugin-approvals`, …) are not in this population: they are |
| 35 | +// not visible from here, and this package cannot import the plugins (they |
| 36 | +// depend on it). |
| 37 | +// - An object an entry names that this package does not declare cannot be |
| 38 | +// judged by (a). (b) therefore requires its entry to name a view, and the |
| 39 | +// set is pinned exactly below so it cannot grow unnoticed. |
| 40 | +// - An object that declares ONLY caller-scoped list views cannot meet (a) |
| 41 | +// without a new view, which is not this file's to add. That set is pinned |
| 42 | +// exactly too, and (b) holds every entry naming it to a named view. |
| 43 | +import { describe, it, expect } from 'vitest'; |
| 44 | +import { AppSchema, NavigationContributionSchema } from '@objectstack/spec/ui'; |
| 45 | + |
| 46 | +import * as PlatformObjects from '../index.js'; |
| 47 | +import { SETUP_NAV_CONTRIBUTIONS } from './setup-nav.contributions.js'; |
| 48 | +import { ACCOUNT_APP } from './account.app.js'; |
| 49 | + |
| 50 | +type NavItem = { |
| 51 | + id?: string; |
| 52 | + type?: string; |
| 53 | + objectName?: string; |
| 54 | + viewName?: string; |
| 55 | + children?: NavItem[]; |
| 56 | +}; |
| 57 | + |
| 58 | +type ListView = { name?: string }; |
| 59 | + |
| 60 | +type ObjectDef = { |
| 61 | + name: string; |
| 62 | + fields: Record<string, unknown>; |
| 63 | + listViews?: Record<string, ListView>; |
| 64 | +}; |
| 65 | + |
| 66 | +type Entry = { |
| 67 | + id: string; |
| 68 | + surface: 'setup' | 'account'; |
| 69 | + objectName: string; |
| 70 | + viewName?: string; |
| 71 | +}; |
| 72 | + |
| 73 | +const CALLER_TOKEN = '{current_user_id}'; |
| 74 | + |
| 75 | +const isCallerScoped = (view: unknown): boolean => |
| 76 | + JSON.stringify(view ?? {}).includes(CALLER_TOKEN); |
| 77 | + |
| 78 | +/** Every `type: 'object'` nav item under `items`, depth-first. */ |
| 79 | +function objectEntries(items: unknown[] | undefined, surface: Entry['surface']): Entry[] { |
| 80 | + const out: Entry[] = []; |
| 81 | + const walk = (list: unknown[] | undefined) => { |
| 82 | + for (const raw of list ?? []) { |
| 83 | + const item = raw as NavItem; |
| 84 | + if (!item) continue; |
| 85 | + if (item.type === 'object') { |
| 86 | + // Thrown, never skipped: an entry this walk cannot read is an entry |
| 87 | + // neither pin judges. |
| 88 | + if (typeof item.id !== 'string' || typeof item.objectName !== 'string') { |
| 89 | + throw new Error(`a ${surface} object entry without an id or objectName: ${JSON.stringify(item)}`); |
| 90 | + } |
| 91 | + out.push({ id: item.id, surface, objectName: item.objectName, viewName: item.viewName }); |
| 92 | + } |
| 93 | + if (Array.isArray(item.children)) walk(item.children); |
| 94 | + } |
| 95 | + }; |
| 96 | + walk(items); |
| 97 | + return out; |
| 98 | +} |
| 99 | + |
| 100 | +const ENTRIES: Entry[] = [ |
| 101 | + ...SETUP_NAV_CONTRIBUTIONS.flatMap((c) => objectEntries(c.items as unknown[], 'setup')), |
| 102 | + ...objectEntries(ACCOUNT_APP.navigation as unknown[], 'account'), |
| 103 | +]; |
| 104 | + |
| 105 | +/** This package's objects by name. Two different definitions under one name is a failure, not a pick. */ |
| 106 | +const CATALOGUE: Map<string, ObjectDef> = (() => { |
| 107 | + const byName = new Map<string, ObjectDef>(); |
| 108 | + for (const value of Object.values(PlatformObjects)) { |
| 109 | + const def = value as unknown as ObjectDef; |
| 110 | + if (!def || typeof def !== 'object' || typeof def.name !== 'string') continue; |
| 111 | + if (!def.fields || typeof def.fields !== 'object') continue; |
| 112 | + const seen = byName.get(def.name); |
| 113 | + if (seen && seen !== def) throw new Error(`two different object definitions export the name ${def.name}`); |
| 114 | + byName.set(def.name, def); |
| 115 | + } |
| 116 | + return byName; |
| 117 | +})(); |
| 118 | + |
| 119 | +const listViewsOf = (name: string): Record<string, ListView> => CATALOGUE.get(name)?.listViews ?? {}; |
| 120 | + |
| 121 | +const NAMED_OBJECTS = [...new Set(ENTRIES.map((e) => e.objectName))].sort(); |
| 122 | +const RESOLVED = NAMED_OBJECTS.filter((name) => CATALOGUE.has(name)); |
| 123 | +const UNRESOLVED = NAMED_OBJECTS.filter((name) => !CATALOGUE.has(name)); |
| 124 | + |
| 125 | +/** Resolved objects whose every declared list view is caller-scoped. */ |
| 126 | +const ONLY_CALLER_SCOPED = RESOLVED.filter((name) => { |
| 127 | + const views = Object.values(listViewsOf(name)); |
| 128 | + return views.length > 0 && views.every(isCallerScoped); |
| 129 | +}); |
| 130 | + |
| 131 | +/** The objects (a) judges: resolved, and declaring no list view or at least one unscoped one. */ |
| 132 | +const JUDGED_BY_A = RESOLVED.filter((name) => !ONLY_CALLER_SCOPED.includes(name)); |
| 133 | + |
| 134 | +/** The entries (b) judges: the object declares a caller-scoped view, or cannot be read from here. */ |
| 135 | +const JUDGED_BY_B = ENTRIES.filter( |
| 136 | + (e) => !CATALOGUE.has(e.objectName) || Object.values(listViewsOf(e.objectName)).some(isCallerScoped), |
| 137 | +); |
| 138 | + |
| 139 | +describe('the population is derived from Setup and Account navigation (#21972)', () => { |
| 140 | + it('reaches object entries in both apps', () => { |
| 141 | + expect(ENTRIES.filter((e) => e.surface === 'setup').length).toBeGreaterThan(0); |
| 142 | + expect(ENTRIES.filter((e) => e.surface === 'account').length).toBeGreaterThan(0); |
| 143 | + }); |
| 144 | + |
| 145 | + // Non-vacuity control, not the population: the objects this card reordered |
| 146 | + // must still be judged by both pins, or a green run says nothing about them. |
| 147 | + it('judges every object whose caller-scoped first view this card moved', () => { |
| 148 | + for (const name of ['sys_user', 'sys_api_key', 'sys_session', 'sys_oauth_application', 'sys_account', 'sys_user_preference']) { |
| 149 | + expect(JUDGED_BY_A, `(a) no longer judges ${name}`).toContain(name); |
| 150 | + expect(JUDGED_BY_B.map((e) => e.objectName), `(b) no longer judges an entry naming ${name}`).toContain(name); |
| 151 | + } |
| 152 | + }); |
| 153 | + |
| 154 | + it('cannot read exactly these named objects from this package', () => { |
| 155 | + // `sys_inbox_message` is `@objectstack/service-messaging`'s; the Account |
| 156 | + // app's Notifications entry names it, and names `mine`. |
| 157 | + expect(UNRESOLVED).toEqual(['sys_inbox_message']); |
| 158 | + }); |
| 159 | + |
| 160 | + it('finds exactly these named objects declaring only caller-scoped list views', () => { |
| 161 | + // `sys_member` declares `mine` alone; only the Account app names it, with |
| 162 | + // `viewName: 'mine'`. Meeting (a) would take a new unscoped view, which is |
| 163 | + // a decision this card reported rather than made. |
| 164 | + expect(ONLY_CALLER_SCOPED).toEqual(['sys_member']); |
| 165 | + }); |
| 166 | +}); |
| 167 | + |
| 168 | +describe('(a) a named object declares a first list view that is not caller-scoped (#21972)', () => { |
| 169 | + it.each(JUDGED_BY_A)('%s', (name) => { |
| 170 | + const [firstName, firstView] = Object.entries(listViewsOf(name))[0] ?? []; |
| 171 | + expect( |
| 172 | + isCallerScoped(firstView), |
| 173 | + `${name} declares the caller-scoped list view "${firstName}" first, so a route naming no view opens the caller's own rows`, |
| 174 | + ).toBe(false); |
| 175 | + }); |
| 176 | +}); |
| 177 | + |
| 178 | +describe('(b) an entry whose object declares a caller-scoped view names its view (#21972)', () => { |
| 179 | + it.each(JUDGED_BY_B.map((e) => [`${e.surface} ${e.id} → ${e.objectName}`, e] as const))('%s', (_label, entry) => { |
| 180 | + expect(entry.viewName, `${entry.id} names no view, so it opens whatever ${entry.objectName} declares first`).toBeTypeOf( |
| 181 | + 'string', |
| 182 | + ); |
| 183 | + if (!CATALOGUE.has(entry.objectName)) return; |
| 184 | + const view = listViewsOf(entry.objectName)[entry.viewName!]; |
| 185 | + expect(view, `${entry.objectName} declares no list view "${entry.viewName}"`).toBeDefined(); |
| 186 | + expect(view.name).toBe(entry.viewName); |
| 187 | + if (entry.surface === 'setup') { |
| 188 | + expect(isCallerScoped(view), `Setup's ${entry.id} names the caller-scoped view "${entry.viewName}"`).toBe(false); |
| 189 | + } |
| 190 | + }); |
| 191 | +}); |
| 192 | + |
| 193 | +describe('the named views reach the served apps (#21972)', () => { |
| 194 | + it('every Setup contribution parses, keeping each object entry its `viewName`', () => { |
| 195 | + for (const contribution of SETUP_NAV_CONTRIBUTIONS) { |
| 196 | + const parsed = NavigationContributionSchema.safeParse(contribution); |
| 197 | + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); |
| 198 | + const kept = objectEntries(parsed.data?.items as unknown[], 'setup'); |
| 199 | + expect(kept).toEqual(objectEntries(contribution.items as unknown[], 'setup')); |
| 200 | + } |
| 201 | + }); |
| 202 | + |
| 203 | + it('the Account app parses, keeping each object entry its `viewName`', () => { |
| 204 | + const parsed = AppSchema.safeParse(ACCOUNT_APP); |
| 205 | + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); |
| 206 | + const kept = objectEntries(parsed.data?.navigation as unknown[], 'account'); |
| 207 | + expect(kept).toEqual(objectEntries(ACCOUNT_APP.navigation as unknown[], 'account')); |
| 208 | + }); |
| 209 | +}); |
0 commit comments