|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { describe, expect, it, vi } from 'vitest'; |
| 4 | +import { ADMIN_FULL_ACCESS_CAPABILITIES } from '@objectstack/spec/identity'; |
| 5 | +import { PLATFORM_CAPABILITY_NAMES } from '@objectstack/spec/security'; |
| 6 | +import { SETUP_APP, SETUP_NAV_CONTRIBUTIONS } from '@objectstack/platform-objects/apps'; |
| 7 | +import { SysUserDetailPage } from '@objectstack/platform-objects/pages'; |
| 8 | +import { printServerReady, type ServerReadyOptions } from './format.js'; |
| 9 | + |
| 10 | +/** |
| 11 | + * #17081 — the `🔑 Dev admin` line must say what that account SEES. |
| 12 | + * |
| 13 | + * ## The defect this pins shut |
| 14 | + * |
| 15 | + * `--seed-admin` (on by default in `os dev`) prints one credential, and it is |
| 16 | + * the ONLY one a first-run operator is given. It is also, by construction, the |
| 17 | + * account with every PLATFORM capability and no APP-declared one. In an app |
| 18 | + * that gates its apps/tabs/nav on `requiredPermissions` — a first-class |
| 19 | + * platform feature — that account resolves to an EMPTY navigation. A downstream |
| 20 | + * maintainer ran `pnpm dev`, signed in with the printed credential, and read the |
| 21 | + * empty shell as a broken product: 「我刚管理员登录进去,看不到 ATS 的左侧菜单」. |
| 22 | + * The app was correct; the banner had pointed them at the one account that sees |
| 23 | + * nothing, and said nothing about it. |
| 24 | + * |
| 25 | + * ## Why a wording pin is the right instrument here, unusually |
| 26 | + * |
| 27 | + * The repo's default is ⛔ don't pin prose. This file pins it because the words |
| 28 | + * ARE the deliverable: the change adds no branch, no key and no behaviour — the |
| 29 | + * only thing that can regress is the sentence. `DEV_ADMIN_BLOCK` below is |
| 30 | + * transcribed from a real render (see the PR body's before/after), not |
| 31 | + * regenerated from the implementation. |
| 32 | + * |
| 33 | + * ## The leg that stops the fix from becoming the defect |
| 34 | + * |
| 35 | + * ADR-0115's `:93` amendment is about a guard that branded a state where the |
| 36 | + * operator could not act on it. A banner that confidently names a route that |
| 37 | + * does not exist is the same defect wearing the fix's clothes. So the route the |
| 38 | + * sentence names — `Setup → Users`, then grant a permission set — is asserted |
| 39 | + * against the DECLARATIONS that make it reachable, from the packages that own |
| 40 | + * them, rather than re-spelled here: |
| 41 | + * |
| 42 | + * 1. `SETUP_APP.requiredPermissions` ⊆ the capabilities this very account |
| 43 | + * holds — so the account we hand over can open the place we send it; |
| 44 | + * 2. the `Users` entry exists in the Setup nav and is itself ungated; |
| 45 | + * 3. the `sys_user` detail page carries the grant surface the sentence |
| 46 | + * promises. |
| 47 | + * |
| 48 | + * Rename any of those and this file reddens, instead of the banner quietly |
| 49 | + * starting to lie. |
| 50 | + */ |
| 51 | + |
| 52 | +/** Strip SGR so assertions hold whether or not chalk colours this run. */ |
| 53 | +const SGR = new RegExp(String.fromCharCode(27) + '\\[[0-9;]*m', 'g'); |
| 54 | + |
| 55 | +function render(opts: Partial<ServerReadyOptions>): string[] { |
| 56 | + const lines: string[] = []; |
| 57 | + const spy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { |
| 58 | + lines.push(args.join(' ').replace(SGR, '')); |
| 59 | + }); |
| 60 | + try { |
| 61 | + printServerReady({ |
| 62 | + externalBaseOrigin: 'http://localhost:4721', |
| 63 | + uiEnabled: true, |
| 64 | + consolePath: '/_console', |
| 65 | + isDev: true, |
| 66 | + pluginCount: 12, |
| 67 | + ...opts, |
| 68 | + } as ServerReadyOptions); |
| 69 | + } finally { |
| 70 | + spy.mockRestore(); |
| 71 | + } |
| 72 | + return lines; |
| 73 | +} |
| 74 | + |
| 75 | +const SEEDED = { email: 'admin@objectos.ai', password: 'admin123' }; |
| 76 | + |
| 77 | +/** |
| 78 | + * The credential block, verbatim, as a real render emits it under NO_COLOR. |
| 79 | + * |
| 80 | + * ⛔ Do not regenerate this from `format.ts`. The first three entries are |
| 81 | + * byte-identical to what shipped before #17081 — the change APPENDS, it does |
| 82 | + * not restate — and that identity is asserted separately below. |
| 83 | + */ |
| 84 | +const DEV_ADMIN_BLOCK = [ |
| 85 | + '', |
| 86 | + ' 🔑 Dev admin: admin@objectos.ai / admin123', |
| 87 | + ' seeded on empty DB · dev only — do not use in production', |
| 88 | + ' platform admin — Setup, Studio and every record, but NO app-declared capability, so', |
| 89 | + ' an app that gates navigation on requiredPermissions may show it an empty menu; grant', |
| 90 | + ' it a permission set under Setup → Users, or sign in as an account your app seeds', |
| 91 | +]; |
| 92 | + |
| 93 | +describe('#17081 — the dev-admin banner says what the account will and will not see', () => { |
| 94 | + it('renders the credential block verbatim, audience sentence included', () => { |
| 95 | + const lines = render({ seededAdmin: SEEDED }); |
| 96 | + const start = lines.findIndex((l) => l.includes('Dev admin:')); |
| 97 | + expect(start).toBeGreaterThan(-1); |
| 98 | + expect(lines.slice(start - 1, start - 1 + DEV_ADMIN_BLOCK.length)).toEqual(DEV_ADMIN_BLOCK); |
| 99 | + }); |
| 100 | + |
| 101 | + it('says what the account WILL see, what it will NOT, and where to get a scoped one', () => { |
| 102 | + const block = render({ seededAdmin: SEEDED }).join('\n'); |
| 103 | + // WILL: the platform surfaces `admin_full_access` actually carries. |
| 104 | + expect(block).toContain('Setup, Studio and every record'); |
| 105 | + // WILL NOT: the mechanism, named by the field an app author writes. |
| 106 | + expect(block).toContain('NO app-declared capability'); |
| 107 | + expect(block).toContain('requiredPermissions'); |
| 108 | + expect(block).toContain('empty menu'); |
| 109 | + // The two routes out. |
| 110 | + expect(block).toContain('permission set under Setup → Users'); |
| 111 | + expect(block).toContain('an account your app seeds'); |
| 112 | + }); |
| 113 | + |
| 114 | + it('appends only — the three pre-existing credential lines are unmoved', () => { |
| 115 | + const lines = render({ seededAdmin: SEEDED }); |
| 116 | + const start = lines.findIndex((l) => l.includes('Dev admin:')); |
| 117 | + expect(lines.slice(start - 1, start + 2)).toEqual(DEV_ADMIN_BLOCK.slice(0, 3)); |
| 118 | + }); |
| 119 | + |
| 120 | + it('is silent when nothing was seeded — the qualification belongs to the event', () => { |
| 121 | + // ADR-0115 `:93`: "a warning about a non-event spends the attention the |
| 122 | + // real ones need". No seed ⇒ no credential ⇒ nothing to qualify. |
| 123 | + const lines = render({}); |
| 124 | + expect(lines.some((l) => l.includes('Dev admin:'))).toBe(false); |
| 125 | + expect(lines.some((l) => l.includes('app-declared capability'))).toBe(false); |
| 126 | + expect(lines.some((l) => l.includes('Setup → Users'))).toBe(false); |
| 127 | + }); |
| 128 | +}); |
| 129 | + |
| 130 | +describe('#17081 — the claim and the route are the platform\'s own declarations', () => { |
| 131 | + const held = new Set<string>(ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions ?? []); |
| 132 | + |
| 133 | + it('"Setup, Studio" is what the seeded admin\'s permission set actually grants', () => { |
| 134 | + // Positive control first: the set is non-empty, so the membership |
| 135 | + // assertions below are reading something. |
| 136 | + expect(held.size).toBeGreaterThan(0); |
| 137 | + expect(held.has('setup.access')).toBe(true); |
| 138 | + expect(held.has('studio.access')).toBe(true); |
| 139 | + // "every record" — the `'*'` super-user bits on the same declaration. |
| 140 | + expect(ADMIN_FULL_ACCESS_CAPABILITIES.objects?.['*']?.viewAllRecords).toBe(true); |
| 141 | + expect(ADMIN_FULL_ACCESS_CAPABILITIES.objects?.['*']?.modifyAllRecords).toBe(true); |
| 142 | + }); |
| 143 | + |
| 144 | + it('"NO app-declared capability" — every capability it holds is a platform built-in', () => { |
| 145 | + const notPlatform = [...held].filter((name) => !PLATFORM_CAPABILITY_NAMES.has(name)); |
| 146 | + expect(notPlatform).toEqual([]); |
| 147 | + // Fabricated control: an app-declared capability is NOT a platform |
| 148 | + // built-in, so the filter above is capable of returning a name. |
| 149 | + expect(PLATFORM_CAPABILITY_NAMES.has('someapp.export_data')).toBe(false); |
| 150 | + }); |
| 151 | + |
| 152 | + it('the route is reachable BY THIS ACCOUNT — Setup gates on a capability it holds', () => { |
| 153 | + expect(SETUP_APP.name).toBe('setup'); |
| 154 | + const gate = SETUP_APP.requiredPermissions ?? []; |
| 155 | + expect(gate.length).toBeGreaterThan(0); |
| 156 | + expect(gate.filter((p) => !held.has(p))).toEqual([]); |
| 157 | + }); |
| 158 | + |
| 159 | + it('"→ Users" is a real, ungated Setup entry on the object that carries the grant', () => { |
| 160 | + const entries = SETUP_NAV_CONTRIBUTIONS |
| 161 | + .filter((c) => c.app === 'setup') |
| 162 | + .flatMap((c) => c.items ?? []); |
| 163 | + const users = entries.find((i) => (i as { objectName?: string }).objectName === 'sys_user') as |
| 164 | + | { label?: unknown; requiredPermissions?: string[] } |
| 165 | + | undefined; |
| 166 | + expect(users).toBeDefined(); |
| 167 | + expect(users!.label).toBe('Users'); |
| 168 | + // Ungated: an operator holding only `setup.access` still sees the entry. |
| 169 | + expect(users!.requiredPermissions ?? []).toEqual([]); |
| 170 | + // …and the group anchor it lands in is ungated too. |
| 171 | + const group = (SETUP_APP.navigation ?? []).find((g) => g.id === 'group_people_org'); |
| 172 | + expect(group).toBeDefined(); |
| 173 | + expect((group as { requiredPermissions?: string[] }).requiredPermissions ?? []).toEqual([]); |
| 174 | + }); |
| 175 | + |
| 176 | + it('"grant it a permission set" is a surface the sys_user page really offers', () => { |
| 177 | + const page = JSON.stringify(SysUserDetailPage); |
| 178 | + expect(page).toContain('sys_user_permission_set'); |
| 179 | + expect(page).toContain('Grant permission set'); |
| 180 | + // Fabricated control: the same haystack does not answer to an invented name. |
| 181 | + expect(page).not.toContain('Grant fabricated permission set'); |
| 182 | + }); |
| 183 | +}); |
0 commit comments