|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * A REAL `os serve` boot of an app that ships its own default record page for |
| 5 | + * `sys_organization`, read back through the metadata door. |
| 6 | + * |
| 7 | + * ## The rule |
| 8 | + * |
| 9 | + * At most one `isDefault: true` record page per object per deployment, and a |
| 10 | + * second is refused at registration with `DEFAULT_RECORD_PAGE_CONFLICT` |
| 11 | + * (maintainer ruling A on objectstack#23015, record 6134056653). `plugin-auth` |
| 12 | + * ships `sys_organization_detail` unless it is constructed with |
| 13 | + * `registerOrganizationRecordPage: false`. `os serve` constructs `AuthPlugin` |
| 14 | + * itself, so it passes that option from the app's own declaration |
| 15 | + * (`stackDeclaresOrganizationRecordPage`, `@objectstack/core`): an app that |
| 16 | + * declares its own `sys_organization` default needs no second switch. |
| 17 | + * |
| 18 | + * ## What each boot is asked |
| 19 | + * |
| 20 | + * - THE CARD'S BOOT: the app declares `orgpage_organization_detail` as the |
| 21 | + * default record page of `sys_organization`. The boot reaches its banner |
| 22 | + * with no conflict, and the served `sys_organization` defaults are exactly |
| 23 | + * that one page, of the app's package. |
| 24 | + * - CONTROL: the same app without that page. `plugin-auth`'s |
| 25 | + * `sys_organization_detail` serves as before — which is also what makes the |
| 26 | + * first reading non-vacuous: the door does list plugin-auth's page when it |
| 27 | + * registers. |
| 28 | + * |
| 29 | + * Both read `sys_user_detail` too: the option removes exactly one page. |
| 30 | + * |
| 31 | + * Signed in as the dev admin that `bin/run-dev.js` seeds (`admin@objectos.ai`). |
| 32 | + */ |
| 33 | + |
| 34 | +import { afterAll, describe, expect, it } from 'vitest'; |
| 35 | +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; |
| 36 | +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; |
| 37 | +import { tmpdir } from 'node:os'; |
| 38 | +import { join } from 'node:path'; |
| 39 | +import { |
| 40 | + CLI, |
| 41 | + E2E_SECRET_KEY, |
| 42 | + TSX, |
| 43 | + childEnv, |
| 44 | + portContentionError, |
| 45 | + portDriftError, |
| 46 | + probeThroughChild, |
| 47 | + randomPort, |
| 48 | +} from './helpers/serve-process.js'; |
| 49 | +import { linkSpec } from './helpers/define-stack-fixture.js'; |
| 50 | + |
| 51 | +const READY = /Press Ctrl\+C to stop/; |
| 52 | + |
| 53 | +const config = (withOrganizationPage: boolean) => ` |
| 54 | +import { defineStack } from '@objectstack/spec'; |
| 55 | +export default defineStack({ |
| 56 | + manifest: { id: 'com.example.orgpage', name: 'orgpage', namespace: 'orgpage', version: '1.0.0', type: 'app' }, |
| 57 | + objects: [{ name: 'orgpage_task', label: 'Task', sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } } }], |
| 58 | + pages: [${withOrganizationPage |
| 59 | + ? "{ name: 'orgpage_organization_detail', label: 'Organization', type: 'record', object: 'sys_organization', isDefault: true, regions: [] }" |
| 60 | + : ''}], |
| 61 | +}, { strict: false }); |
| 62 | +`; |
| 63 | + |
| 64 | +const dirs: string[] = []; |
| 65 | +const children: ChildProcessWithoutNullStreams[] = []; |
| 66 | + |
| 67 | +function project(withOrganizationPage: boolean): string { |
| 68 | + const dir = mkdtempSync(join(tmpdir(), 'os-org-record-page-')); |
| 69 | + dirs.push(dir); |
| 70 | + writeFileSync(join(dir, 'objectstack.config.ts'), config(withOrganizationPage), 'utf8'); |
| 71 | + linkSpec(dir); |
| 72 | + return dir; |
| 73 | +} |
| 74 | + |
| 75 | +interface LiveServe { |
| 76 | + child: ChildProcessWithoutNullStreams; |
| 77 | + output: () => string; |
| 78 | +} |
| 79 | + |
| 80 | +function bootServe(dir: string, port: string): Promise<LiveServe> { |
| 81 | + return new Promise((resolveBoot, rejectBoot) => { |
| 82 | + const child = spawn(TSX, [CLI, 'serve', 'objectstack.config.ts', '-p', port], { |
| 83 | + cwd: dir, |
| 84 | + stdio: ['pipe', 'pipe', 'pipe'], |
| 85 | + // `childEnv`, never a bare `...process.env` — see its header (commit 1ddda1d00). |
| 86 | + env: childEnv({ |
| 87 | + NO_COLOR: '1', |
| 88 | + OS_DATABASE_URL: ':memory:', |
| 89 | + OS_LOG_LEVEL: 'warn', |
| 90 | + OS_DISABLE_CONSOLE: '1', |
| 91 | + OS_SECRET_KEY: E2E_SECRET_KEY, |
| 92 | + }), |
| 93 | + }) as ChildProcessWithoutNullStreams; |
| 94 | + children.push(child); |
| 95 | + const what = `os serve in ${dir}`; |
| 96 | + let out = ''; |
| 97 | + let settled = false; |
| 98 | + const settle = (err: Error | null) => { |
| 99 | + if (settled) return; |
| 100 | + settled = true; |
| 101 | + clearTimeout(timer); |
| 102 | + if (err) rejectBoot(err); |
| 103 | + else resolveBoot({ child, output: () => out }); |
| 104 | + }; |
| 105 | + const timer = setTimeout( |
| 106 | + () => settle(new Error(`${what} never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)), |
| 107 | + 180_000, |
| 108 | + ); |
| 109 | + const onData = (d: unknown) => { |
| 110 | + out += String(d); |
| 111 | + // The child is the authority on the port it bound (#12525). |
| 112 | + if (READY.test(out)) settle(portDriftError(out, what, port)); |
| 113 | + }; |
| 114 | + child.stdout.on('data', onData); |
| 115 | + child.stderr.on('data', onData); |
| 116 | + child.on('exit', (code) => |
| 117 | + settle( |
| 118 | + portContentionError(out, what, port) |
| 119 | + ?? new Error(`${what} exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`), |
| 120 | + ), |
| 121 | + ); |
| 122 | + }); |
| 123 | +} |
| 124 | + |
| 125 | +async function stop(child: ChildProcessWithoutNullStreams): Promise<void> { |
| 126 | + if (child.exitCode !== null || child.signalCode !== null) return; |
| 127 | + await new Promise<void>((done) => { |
| 128 | + const give = setTimeout(() => { |
| 129 | + try { |
| 130 | + child.kill('SIGKILL'); |
| 131 | + } catch { |
| 132 | + /* already gone */ |
| 133 | + } |
| 134 | + done(); |
| 135 | + }, 10_000); |
| 136 | + child.once('exit', () => { |
| 137 | + clearTimeout(give); |
| 138 | + done(); |
| 139 | + }); |
| 140 | + try { |
| 141 | + child.kill('SIGTERM'); |
| 142 | + } catch { |
| 143 | + clearTimeout(give); |
| 144 | + done(); |
| 145 | + } |
| 146 | + }); |
| 147 | +} |
| 148 | + |
| 149 | +/** One HTTP exchange, attributed to the child if the transport fails (#15653). ⛔ No assertion inside. */ |
| 150 | +async function request(serve: LiveServe, what: string, url: string, init: RequestInit = {}): Promise<{ status: number; body: any }> { |
| 151 | + return probeThroughChild( |
| 152 | + { |
| 153 | + child: serve.child, |
| 154 | + transcript: () => `\n--- child output ---\n${serve.output().slice(-4000)}`, |
| 155 | + label: 'serve-organization-record-page', |
| 156 | + what, |
| 157 | + }, |
| 158 | + async () => { |
| 159 | + const r = await fetch(url, init); |
| 160 | + const text = await r.text(); |
| 161 | + let body: unknown = text; |
| 162 | + try { body = JSON.parse(text); } catch { /* keep the text */ } |
| 163 | + return { status: r.status, body }; |
| 164 | + }, |
| 165 | + ); |
| 166 | +} |
| 167 | + |
| 168 | +/** Every default record page the booted deployment serves for `object`, as `page of package`. */ |
| 169 | +type Defaults = Record<string, string[]>; |
| 170 | + |
| 171 | +async function servedDefaults(withOrganizationPage: boolean): Promise<{ defaults: Defaults; output: string }> { |
| 172 | + const port = randomPort(); |
| 173 | + const base = `http://localhost:${port}/api/v1`; |
| 174 | + const serve = await bootServe(project(withOrganizationPage), port); |
| 175 | + try { |
| 176 | + const signIn = await request(serve, 'the sign-in probe', `${base}/auth/sign-in/email`, { |
| 177 | + method: 'POST', |
| 178 | + headers: { 'content-type': 'application/json' }, |
| 179 | + body: JSON.stringify({ email: 'admin@objectos.ai', password: 'admin123' }), |
| 180 | + }); |
| 181 | + const token = signIn.body?.token; |
| 182 | + if (signIn.status !== 200 || typeof token !== 'string') { |
| 183 | + throw new Error(`sign-in answered ${signIn.status}: ${JSON.stringify(signIn.body)}\n--- output ---\n${serve.output().slice(-3000)}`); |
| 184 | + } |
| 185 | + const pages = await request(serve, 'the page list', `${base}/meta/page`, { |
| 186 | + headers: { authorization: `Bearer ${token}` }, |
| 187 | + }); |
| 188 | + if (pages.status !== 200 || !Array.isArray(pages.body?.items)) { |
| 189 | + throw new Error(`GET /meta/page answered ${pages.status}: ${JSON.stringify(pages.body).slice(0, 500)}`); |
| 190 | + } |
| 191 | + const defaults: Defaults = {}; |
| 192 | + for (const page of pages.body.items) { |
| 193 | + const isRecord = page?.type === undefined || page?.type === 'record'; |
| 194 | + if (page?.isDefault !== true || !isRecord || typeof page?.object !== 'string') continue; |
| 195 | + (defaults[page.object] ??= []).push(`${page.name} of ${page._packageId}`); |
| 196 | + } |
| 197 | + return { defaults, output: serve.output() }; |
| 198 | + } finally { |
| 199 | + await stop(serve.child); |
| 200 | + } |
| 201 | +} |
| 202 | + |
| 203 | +afterAll(async () => { |
| 204 | + for (const child of children) await stop(child); |
| 205 | + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); |
| 206 | +}); |
| 207 | + |
| 208 | +describe('os serve — one default record page for sys_organization (ruling A on #23015)', () => { |
| 209 | + it( |
| 210 | + 'an app with its own sys_organization default boots, and its page is the single default', |
| 211 | + async () => { |
| 212 | + const { defaults, output } = await servedDefaults(true); |
| 213 | + expect(output, 'the boot was refused').not.toMatch(/DEFAULT_RECORD_PAGE_CONFLICT|Default record page conflict/); |
| 214 | + expect(defaults.sys_organization).toEqual(['orgpage_organization_detail of com.example.orgpage']); |
| 215 | + expect(defaults.sys_user).toEqual(['sys_user_detail of com.objectstack.plugin-auth']); |
| 216 | + }, |
| 217 | + 300_000, |
| 218 | + ); |
| 219 | + |
| 220 | + it( |
| 221 | + 'control: without the app page, plugin-auth\'s page serves as before', |
| 222 | + async () => { |
| 223 | + const { defaults } = await servedDefaults(false); |
| 224 | + expect(defaults.sys_organization).toEqual(['sys_organization_detail of com.objectstack.plugin-auth']); |
| 225 | + expect(defaults.sys_user).toEqual(['sys_user_detail of com.objectstack.plugin-auth']); |
| 226 | + }, |
| 227 | + 300_000, |
| 228 | + ); |
| 229 | +}); |
0 commit comments