|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// The print service end to end, on a real showcase boot: `PrintServicePlugin` |
| 4 | +// mints a session for the requesting user (`IAuthService.mintPrincipalSession`), |
| 5 | +// headless Chromium loads the REAL console with only that session, prints the |
| 6 | +// invoice's print page, and the PDF is archived on the invoice as the requester |
| 7 | +// — on BOTH placements: |
| 8 | +// |
| 9 | +// - **local** — `chromiumPath`, the plugin launches the executable; |
| 10 | +// - **remote** — `browserEndpoint`: this test spawns the same executable with |
| 11 | +// its DevTools WebSocket endpoint open and hands the plugin that endpoint, |
| 12 | +// so the browser is reached only through the remote placement's connect. |
| 13 | +// |
| 14 | +// Per placement it pins: |
| 15 | +// - the console booted under the requester, never the system (every |
| 16 | +// `get-session` the browser made answered the requester); |
| 17 | +// - a real PDF archived as one `sys_attachment` on the invoice, with |
| 18 | +// `sys_file.owner_id` and `sys_attachment.uploaded_by` the requester; |
| 19 | +// - the minted session gone after the print — after a success and after a |
| 20 | +// forced failure (the archive's byte store refusing after the render); |
| 21 | +// - the minted cookie in no refusal message. |
| 22 | +// |
| 23 | +// Needs two things this suite's CI job does not build: the Console bundle |
| 24 | +// (`packages/console/dist`, `pnpm objectui:build`) and a Chromium |
| 25 | +// (`OS_TEST_PRINT_CHROMIUM_PATH`, or Playwright's provisioned |
| 26 | +// `$PLAYWRIGHT_BROWSERS_PATH/chromium`). Without either the file is skipped and |
| 27 | +// says so: a skipped render is not a passed one. |
| 28 | + |
| 29 | +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; |
| 30 | +import { spawn, type ChildProcess } from 'node:child_process'; |
| 31 | +import { accessSync, constants, existsSync, mkdtempSync, rmSync } from 'node:fs'; |
| 32 | +import { createServer, type Server } from 'node:http'; |
| 33 | +import { tmpdir } from 'node:os'; |
| 34 | +import { join } from 'node:path'; |
| 35 | +import { fileURLToPath } from 'node:url'; |
| 36 | +import type { VerifyStack } from '@objectstack/verify'; |
| 37 | +import { StorageServicePlugin } from '@objectstack/service-storage'; |
| 38 | +import { AuditPlugin } from '@objectstack/plugin-audit'; |
| 39 | +import type { IAuthService, IDataEngine, IStorageService, PrincipalSession } from '@objectstack/spec/contracts'; |
| 40 | +import { bootShowcase } from './showcase-boot.js'; |
| 41 | +import { createConsoleStaticPlugin } from '../../../cli/src/utils/console.js'; |
| 42 | +import { PrintServicePlugin } from '../../../services/service-print/src/print-service-plugin.js'; |
| 43 | +import type { IPrintService } from '../../../services/service-print/src/print-service.js'; |
| 44 | + |
| 45 | +const SYS = { isSystem: true } as const; |
| 46 | +const PAGE = 'showcase_invoice_document'; |
| 47 | +const APP = 'com.example.showcase'; |
| 48 | +const CONSOLE_DIST = fileURLToPath(new URL('../../../console/dist', import.meta.url)); |
| 49 | + |
| 50 | +function chromiumUnderTest(): string | undefined { |
| 51 | + const candidates = [ |
| 52 | + process.env.OS_TEST_PRINT_CHROMIUM_PATH, |
| 53 | + process.env.PLAYWRIGHT_BROWSERS_PATH ? join(process.env.PLAYWRIGHT_BROWSERS_PATH, 'chromium') : undefined, |
| 54 | + ]; |
| 55 | + for (const candidate of candidates) { |
| 56 | + if (!candidate) continue; |
| 57 | + try { |
| 58 | + accessSync(candidate, constants.X_OK); |
| 59 | + return candidate; |
| 60 | + } catch { |
| 61 | + // not this one |
| 62 | + } |
| 63 | + } |
| 64 | + return undefined; |
| 65 | +} |
| 66 | + |
| 67 | +const CHROMIUM = chromiumUnderTest(); |
| 68 | +const CONSOLE_BUILT = existsSync(join(CONSOLE_DIST, 'index.html')); |
| 69 | +if (!CHROMIUM || !CONSOLE_BUILT) { |
| 70 | + console.warn( |
| 71 | + `[dogfood] print-service-render.dogfood.test.ts SKIPPED: ${!CONSOLE_BUILT ? 'no built Console (packages/console/dist; pnpm objectui:build)' : ''}` + |
| 72 | + `${!CONSOLE_BUILT && !CHROMIUM ? ' and ' : ''}${!CHROMIUM ? 'no Chromium (OS_TEST_PRINT_CHROMIUM_PATH or PLAYWRIGHT_BROWSERS_PATH)' : ''}. ` + |
| 73 | + 'The real render was NOT measured on this host.', |
| 74 | + ); |
| 75 | +} |
| 76 | + |
| 77 | +async function freePort(): Promise<number> { |
| 78 | + const s = createServer(); |
| 79 | + await new Promise<void>((r) => s.listen(0, '127.0.0.1', r)); |
| 80 | + const { port } = s.address() as { port: number }; |
| 81 | + await new Promise<void>((r) => s.close(() => r())); |
| 82 | + return port; |
| 83 | +} |
| 84 | + |
| 85 | +async function spawnRemoteBrowser(executable: string): Promise<{ child: ChildProcess; endpoint: string; dir: string }> { |
| 86 | + const dir = mkdtempSync(join(tmpdir(), 'os-print-render-remote-')); |
| 87 | + const child = spawn( |
| 88 | + executable, |
| 89 | + ['--headless=new', '--no-sandbox', '--no-first-run', '--remote-debugging-address=127.0.0.1', '--remote-debugging-port=0', `--user-data-dir=${dir}`, 'about:blank'], |
| 90 | + { stdio: ['ignore', 'ignore', 'pipe'] }, |
| 91 | + ); |
| 92 | + const endpoint = await new Promise<string>((resolve, reject) => { |
| 93 | + let stderr = ''; |
| 94 | + const timer = setTimeout(() => reject(new Error('the remote browser printed no DevTools endpoint')), 20_000); |
| 95 | + child.stderr!.on('data', (chunk) => { |
| 96 | + stderr += String(chunk); |
| 97 | + const m = stderr.match(/DevTools listening on (ws:\/\/\S+)/); |
| 98 | + if (m) { |
| 99 | + clearTimeout(timer); |
| 100 | + resolve(m[1]!); |
| 101 | + } |
| 102 | + }); |
| 103 | + }); |
| 104 | + return { child, endpoint, dir }; |
| 105 | +} |
| 106 | + |
| 107 | +interface Refusal { |
| 108 | + code?: unknown; |
| 109 | + status?: unknown; |
| 110 | + message: string; |
| 111 | +} |
| 112 | + |
| 113 | +async function refusalOf(p: Promise<unknown>): Promise<Refusal> { |
| 114 | + try { |
| 115 | + await p; |
| 116 | + } catch (err) { |
| 117 | + const e = err as Refusal; |
| 118 | + return { code: e.code, status: e.status, message: e.message }; |
| 119 | + } |
| 120 | + throw new Error('expected a refusal, the call resolved'); |
| 121 | +} |
| 122 | + |
| 123 | +for (const placement of ['local', 'remote'] as const) { |
| 124 | + describe.skipIf(!CHROMIUM || !CONSOLE_BUILT)(`print service real render (${placement} placement)`, () => { |
| 125 | + let stack: VerifyStack; |
| 126 | + let server: Server; |
| 127 | + let remote: { child: ChildProcess; endpoint: string; dir: string } | undefined; |
| 128 | + let rootDir: string; |
| 129 | + let print: IPrintService; |
| 130 | + let ql: IDataEngine; |
| 131 | + /** The user id of every `get-session` the browser's requests resolved to. */ |
| 132 | + const sessionUsers: Array<string | null> = []; |
| 133 | + /** Each session the plugin minted: its cookie, captured to prove it is gone afterwards. Never printed. */ |
| 134 | + const minted: Array<PrincipalSession['cookie']> = []; |
| 135 | + |
| 136 | + beforeAll(async () => { |
| 137 | + const port = await freePort(); |
| 138 | + const engine = |
| 139 | + placement === 'local' |
| 140 | + ? { chromiumPath: CHROMIUM! } |
| 141 | + : { browserEndpoint: (remote = await spawnRemoteBrowser(CHROMIUM!)).endpoint }; |
| 142 | + rootDir = mkdtempSync(join(tmpdir(), 'print-render-storage-')); |
| 143 | + stack = await bootShowcase({ |
| 144 | + extraPlugins: [ |
| 145 | + new StorageServicePlugin({ adapter: 'local', local: { rootDir }, bindToSettings: false }), |
| 146 | + new AuditPlugin(), |
| 147 | + createConsoleStaticPlugin(CONSOLE_DIST), |
| 148 | + new PrintServicePlugin({ ...engine, consoleUrl: `http://127.0.0.1:${port}/_console` }), |
| 149 | + ], |
| 150 | + }); |
| 151 | + // The real HTTP door the browser reaches: every request handed to the |
| 152 | + // booted stack as it arrives, every answer handed back as it is. |
| 153 | + server = createServer(async (req, res) => { |
| 154 | + const chunks: Buffer[] = []; |
| 155 | + for await (const c of req) chunks.push(c as Buffer); |
| 156 | + const headers: Record<string, string> = {}; |
| 157 | + for (const [k, v] of Object.entries(req.headers)) if (typeof v === 'string') headers[k] = v; |
| 158 | + const hasBody = chunks.length > 0 && req.method !== 'GET' && req.method !== 'HEAD'; |
| 159 | + const answer = await stack.raw(req.url!, { method: req.method, headers, ...(hasBody ? { body: Buffer.concat(chunks) } : {}) } as RequestInit); |
| 160 | + const out: Record<string, string | string[]> = {}; |
| 161 | + answer.headers.forEach((v, k) => { |
| 162 | + if (k !== 'set-cookie' && k !== 'content-encoding' && k !== 'content-length') out[k] = v; |
| 163 | + }); |
| 164 | + const setCookie = (answer.headers as unknown as { getSetCookie?: () => string[] }).getSetCookie?.() ?? []; |
| 165 | + if (setCookie.length) out['set-cookie'] = setCookie; |
| 166 | + const body = Buffer.from(await answer.arrayBuffer()); |
| 167 | + if (req.url?.startsWith('/api/v1/auth/get-session')) { |
| 168 | + try { |
| 169 | + sessionUsers.push((JSON.parse(body.toString('utf8')) as { user?: { id?: string } } | null)?.user?.id ?? null); |
| 170 | + } catch { |
| 171 | + sessionUsers.push(null); |
| 172 | + } |
| 173 | + } |
| 174 | + res.writeHead(answer.status, out); |
| 175 | + res.end(body); |
| 176 | + }); |
| 177 | + await new Promise<void>((r) => server.listen(port, '127.0.0.1', r)); |
| 178 | + |
| 179 | + print = stack.kernel.getService<IPrintService>('print'); |
| 180 | + ql = stack.kernel.getService<IDataEngine>('objectql'); |
| 181 | + // Observe each mint (the plugin reads the auth service per call): keep the |
| 182 | + // cookie so the test can ask, afterwards, whether it still opens a session. |
| 183 | + const auth = stack.kernel.getService<IAuthService>('auth'); |
| 184 | + const mint = auth.mintPrincipalSession!.bind(auth); |
| 185 | + auth.mintPrincipalSession = async (...args) => { |
| 186 | + const session = await mint(...args); |
| 187 | + minted.push(session.cookie); |
| 188 | + return session; |
| 189 | + }; |
| 190 | + }, 240_000); |
| 191 | + |
| 192 | + afterAll(async () => { |
| 193 | + server?.close(); |
| 194 | + await stack?.stop(); |
| 195 | + if (remote) { |
| 196 | + const exited = new Promise<void>((r) => (remote!.child.exitCode !== null ? r() : remote!.child.once('exit', () => r()))); |
| 197 | + remote.child.kill(); |
| 198 | + await exited; |
| 199 | + rmSync(remote.dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); |
| 200 | + } |
| 201 | + if (rootDir) rmSync(rootDir, { recursive: true, force: true }); |
| 202 | + }); |
| 203 | + |
| 204 | + const opensASession = async (cookie: PrincipalSession['cookie']): Promise<[number, string | undefined]> => { |
| 205 | + const res = await stack.raw('/api/v1/auth/get-session', { headers: { cookie: `${cookie.name}=${cookie.value}` } }); |
| 206 | + const json = (await res.json()) as { user?: { id?: string }; error?: { code?: string } } | null; |
| 207 | + return [res.status, json?.user?.id ?? json?.error?.code]; |
| 208 | + }; |
| 209 | + |
| 210 | + const newInvoice = async (name: string, owner: string): Promise<string> => { |
| 211 | + const [seeded] = await ql.find('showcase_invoice', { limit: 1 }, { context: SYS }); |
| 212 | + return String((await ql.insert('showcase_invoice', { name, owner, account: seeded!.account, status: 'draft' }, { context: SYS })).id); |
| 213 | + }; |
| 214 | + |
| 215 | + it(`the engine is ready on the ${placement} placement`, () => { |
| 216 | + expect(print.status()).toMatchObject({ state: 'ready', placement }); |
| 217 | + }); |
| 218 | + |
| 219 | + it('prints the invoice through the real console as the requester, archives it as theirs, and the session is gone', async () => { |
| 220 | + const token = await stack.signIn(); |
| 221 | + const ctx = await stack.contextFor(token); |
| 222 | + const invoice = await newInvoice(`INV-RENDER-${placement}`, 'admin@objectos.ai'); |
| 223 | + sessionUsers.length = 0; |
| 224 | + minted.length = 0; |
| 225 | + |
| 226 | + const out = await print.printRecordToFile({ app: APP, object: 'showcase_invoice', recordId: invoice, page: PAGE }, ctx); |
| 227 | + |
| 228 | + // The console booted under the requester, and only them. |
| 229 | + expect(sessionUsers.length).toBeGreaterThan(0); |
| 230 | + expect(new Set(sessionUsers)).toEqual(new Set([ctx.userId])); |
| 231 | + // A real PDF, archived on the invoice as the requester's. |
| 232 | + const file = await ql.findOne('sys_file', { where: { id: out.fileId } }, { context: SYS }); |
| 233 | + const attachment = await ql.findOne('sys_attachment', { where: { id: out.attachmentId } }, { context: SYS }); |
| 234 | + expect(file).toMatchObject({ status: 'committed', mime_type: 'application/pdf', owner_id: ctx.userId }); |
| 235 | + expect(attachment).toMatchObject({ parent_object: 'showcase_invoice', parent_id: invoice, file_id: out.fileId, uploaded_by: ctx.userId }); |
| 236 | + const bytes = await stack.kernel.getService<IStorageService>('storage').download(String(file!.key)); |
| 237 | + expect(bytes.subarray(0, 5).toString('latin1')).toBe('%PDF-'); |
| 238 | + expect(bytes.byteLength).toBe(out.size); |
| 239 | + // The minted session is gone. |
| 240 | + expect(minted).toHaveLength(1); |
| 241 | + expect(await opensASession(minted[0]!)).toEqual([401, 'UNAUTHENTICATED']); |
| 242 | + }, 120_000); |
| 243 | + |
| 244 | + it('a print that fails after the render still revokes the session, stores nothing, and names no credential', async () => { |
| 245 | + const token = await stack.signIn(); |
| 246 | + const ctx = await stack.contextFor(token); |
| 247 | + const invoice = await newInvoice(`INV-RENDER-FAIL-${placement}`, 'admin@objectos.ai'); |
| 248 | + minted.length = 0; |
| 249 | + // Force the failure after the render: the archive's byte store refuses the upload once. |
| 250 | + const storage = stack.kernel.getService<IStorageService>('storage'); |
| 251 | + const upload = storage.upload.bind(storage); |
| 252 | + storage.upload = async () => { |
| 253 | + storage.upload = upload; |
| 254 | + throw new Error('forced byte-store failure'); |
| 255 | + }; |
| 256 | + |
| 257 | + const r = await refusalOf(print.printRecordToFile({ app: APP, object: 'showcase_invoice', recordId: invoice, page: PAGE }, ctx)); |
| 258 | + |
| 259 | + expect(r.message).toContain('forced byte-store failure'); |
| 260 | + expect(minted).toHaveLength(1); |
| 261 | + expect(r.message).not.toContain(minted[0]!.value); |
| 262 | + expect(await opensASession(minted[0]!)).toEqual([401, 'UNAUTHENTICATED']); |
| 263 | + expect(await ql.find('sys_attachment', { where: { parent_id: invoice } }, { context: SYS })).toEqual([]); |
| 264 | + }, 120_000); |
| 265 | + }); |
| 266 | +} |
0 commit comments