Skip to content

Commit 9252cd1

Browse files
committed
test(dogfood): the print service's real render on both placements, as the requester, with the session gone after
Claude-Session: https://claude.ai/code/session_01W2XFCTBfK4LabgqovmvSgK Co-authored-by: Claude <noreply@anthropic.com>
1 parent fff7ed7 commit 9252cd1

1 file changed

Lines changed: 266 additions & 0 deletions

File tree

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
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

Comments
 (0)