Files
open-design/apps/daemon/tests/vela-touchpoint-content-assembly.test.ts
ae321ab2e6 fix(cms): fence offline replay across revocation and process death (#8676)
* fix(cms): fence offline replay across revocation and process death

* feat(cms): define paginated Test catalog and immutable runtime context

* fix(cms): synchronize Test and Production host presentation lifecycles

* test(cms): retain cross-host lifecycle and browser acceptance regressions

* fix(cms): retain request tickets through body classification

---------

Co-authored-by: heggria <bshengtao@gmail.com>
Co-authored-by: ivy-tinggg <986810707@qq.com>
2026-10-15 07:37:41 +00:00

801 lines
32 KiB
TypeScript

// OPEND-3371 at the daemon HTTP boundary — the cheapest layer that can see the
// symptom (AGENTS.md "Try the cheapest layer first").
//
// The browser is not changed by this ticket and must not be able to tell that
// anything happened: it asks the same URL and gets the same JSON. What changes
// is the WAN hop behind the daemon, where a steady-state refresh stops carrying
// the content package the daemon already holds.
//
// Two properties are load-bearing and each has its own named case below:
//
// 1. A request the daemon holds nothing for is byte-for-byte today's request
// and today's response — no new parameters, no `contentOmitted`.
// 2. Once durable authority admission succeeds, content-cache failures cost
// bandwidth, never the online campaign. A trimmed reply it cannot rebuild
// is re-asked in full. Failed authority admission must not dispatch upstream.
import { createHash } from 'node:crypto';
import express from 'express';
import fs from 'node:fs';
import {
createServer,
request as httpRequest,
type IncomingHttpHeaders,
type IncomingMessage,
type Server,
type ServerResponse,
} from 'node:http';
import type { AddressInfo } from 'node:net';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import zlib from 'node:zlib';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { AppConfigPrefs } from '../src/app-config.js';
import { registerVelaRoutes } from '../src/routes/vela.js';
const digest = (value: string) => `sha256:${createHash('sha256').update(value).digest('hex')}`;
const base64 = (value: string) => Buffer.from(value, 'utf8').toString('base64');
const SHARED = `export const shared = ${JSON.stringify('x'.repeat(4096))};`;
const ENTRY = `import './shared.js'; export function mount(root) { root.textContent = ${JSON.stringify('y'.repeat(4096))}; }`;
const PLACEMENT = 'opend.home.campaign-modal';
const LOCALE = 'en-US';
const manifest = {
formatVersion: 2,
runtimeKind: 'web-component',
runtimeApiVersion: 1,
platformWrapperVersion: 'vela-touchpoint-wrapper-v1',
sdkVersion: 'vela-touchpoint-sdk-v1',
contentLine: 'production',
placements: [
{
key: PLACEMENT,
entry: 'component.js',
resources: ['shared.js'],
locales: [LOCALE],
requiredCapabilities: [],
staticActions: [],
},
],
resources: ['component.js', 'shared.js'],
images: [],
};
const FULL_RESPONSE = {
deploymentId: 'deployment-1',
activityId: 'activity-1',
snapshotHash: 'sha256:snapshot',
artifactHash: 'sha256:artifact',
manifestHash: 'sha256:manifest',
placementKey: PLACEMENT,
requiredCapabilities: [],
staticActions: [],
testContext: null,
content: {
id: 'version-1',
placementKey: PLACEMENT,
locale: LOCALE,
manifest,
manifestHash: digest(JSON.stringify(manifest)),
entryPath: 'component.js',
entryDigest: digest(ENTRY),
entryModule: ENTRY,
resources: [
{ path: 'component.js', digest: digest(ENTRY), bytes: base64(ENTRY) },
{ path: 'shared.js', digest: digest(SHARED), bytes: base64(SHARED) },
],
runtime: {
kind: 'web-component',
apiVersion: 1,
wrapperVersion: 'vela-touchpoint-wrapper-v1',
sdkVersion: 'vela-touchpoint-sdk-v1',
},
buildIdentity: { fingerprint: 'fixed' },
},
serverTime: '2026-09-18T00:00:00.000Z',
startsAt: '2026-09-18T00:00:00.000Z',
endsAt: '2026-09-19T00:00:00.000Z',
authorizationExpiresAt: '2026-09-18T01:00:00.000Z',
touchpointDecisionId: 'decision-1',
};
/** C3: today's object with `content` replaced in place by `contentOmitted: true`. */
const TRIMMED_RESPONSE = (() => {
const trimmed: Record<string, unknown> = {};
for (const [field, value] of Object.entries(FULL_RESPONSE)) {
if (field === 'content') trimmed.contentOmitted = true;
else trimmed[field] = value;
}
return trimmed;
})();
type HeldParams = Readonly<{ heldContentId: string | null; heldContentLocale: string | null }>;
type UpstreamCall = Readonly<{ path: string } & HeldParams>;
/**
* A case takes the fake Vela over when it needs to control WHEN a reply lands,
* not just what it says. Everything else keeps the default handler below.
*/
type UpstreamHandler = (req: IncomingMessage, res: ServerResponse, held: HeldParams) => void;
/**
* The server's own rule, which no case here may bypass: it may omit the
* content only when the caller already holds exactly the version it decided to
* serve. A fake that trimmed on a flag instead would be exercising a protocol
* nobody implements.
*/
const decisionPayload = (served: typeof FULL_RESPONSE, held: HeldParams): string => {
if (held.heldContentId !== served.content.id || held.heldContentLocale !== served.content.locale)
return JSON.stringify(served);
const trimmed: Record<string, unknown> = {};
for (const [field, value] of Object.entries(served)) {
if (field === 'content') trimmed.contentOmitted = true;
else trimmed[field] = value;
}
return JSON.stringify(trimmed);
};
let dataDir: string;
let upstream: Server;
let daemon: Server;
let baseUrl: string;
let calls: UpstreamCall[];
/** Bytes of every response body Vela sent over the (notional) WAN hop. */
let upstreamBytes: number[];
/** When false the fake Vela is an un-upgraded one: it strips the new parameters. */
let supportsTrimming: boolean;
let status: number;
let errorBody: unknown;
let upstreamHandler: UpstreamHandler | null;
const listen = (server: Server) =>
new Promise<AddressInfo>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
server.off('error', reject);
resolve(server.address() as AddressInfo);
});
});
const lifecycles = new Map<Server, ReturnType<typeof registerVelaRoutes>>();
const close = async (server: Server) => {
if (!server) return; // A rejected setup has no daemon to close.
await lifecycles.get(server)?.close();
lifecycles.delete(server);
await new Promise<void>((resolve) => server.close(() => resolve()));
};
beforeEach(async () => {
dataDir = mkdtempSync(path.join(tmpdir(), 'od-touchpoint-assembly-'));
calls = [];
upstreamBytes = [];
supportsTrimming = true;
status = 200;
errorBody = null;
upstreamHandler = null;
upstream = createServer((req, res) => {
const url = new URL(req.url ?? '/', 'http://upstream');
const held: HeldParams = {
heldContentId: url.searchParams.get('heldContentId'),
heldContentLocale: url.searchParams.get('heldContentLocale'),
};
calls.push({ path: url.pathname, ...held });
if (upstreamHandler) {
upstreamHandler(req, res, held);
return;
}
res.setHeader('content-type', 'application/json');
res.statusCode = status;
const trim =
supportsTrimming &&
status === 200 &&
held.heldContentId === FULL_RESPONSE.content.id &&
held.heldContentLocale === FULL_RESPONSE.content.locale;
const payload = JSON.stringify(
status === 200 ? (trim ? TRIMMED_RESPONSE : FULL_RESPONSE) : errorBody,
);
upstreamBytes.push(Buffer.byteLength(payload));
res.end(payload);
});
const upstreamAddress = await listen(upstream);
const app = express();
app.use(express.json());
const lifecycle = registerVelaRoutes(app, {
paths: { RUNTIME_DATA_DIR: dataDir },
appConfig: { readAppConfig: async () => ({ agentCliEnv: {} }) as AppConfigPrefs },
http: {},
env: {
VELA_CONTROL_KEY: 'ck-test',
VELA_API_URL: `http://127.0.0.1:${upstreamAddress.port}`,
},
});
daemon = createServer(app);
lifecycles.set(daemon, lifecycle);
const daemonAddress = await listen(daemon);
baseUrl = `http://127.0.0.1:${daemonAddress.port}`;
});
afterEach(async () => {
await close(daemon);
await close(upstream);
try {
fs.chmodSync(path.join(dataDir, 'touchpoint-content-cache'), 0o700);
} catch {
/* absent or already writable */
}
rmSync(dataDir, { recursive: true, force: true });
});
const decide = async () => {
const response = await fetch(
`${baseUrl}/api/touchpoints/production-runtime?placementKey=${PLACEMENT}&locale=${LOCALE}`,
);
return { status: response.status, text: await response.text() };
};
const decisionUrl = () =>
`${baseUrl}/api/touchpoints/production-runtime?placementKey=${PLACEMENT}&locale=${LOCALE}`;
/**
* The same request `decide` makes, over a raw client rather than `fetch`.
*
* `fetch` transparently decompresses, which would hide the difference between
* a body this proxy forwarded still compressed and one it expanded itself.
*/
const rawDecide = (headers: Record<string, string> = {}) =>
new Promise<{ status: number; headers: IncomingHttpHeaders; body: Buffer }>((resolve, reject) => {
const request = httpRequest(decisionUrl(), { headers }, (response) => {
const chunks: Buffer[] = [];
response.on('data', (chunk: Buffer) => chunks.push(chunk));
response.on('end', () =>
resolve({
status: response.statusCode ?? 0,
headers: response.headers,
body: Buffer.concat(chunks),
}),
);
});
request.on('error', reject);
request.end();
});
/**
* The one (environment, account) directory these cases write into. OPEND-3436
* isolates the store by scope, so the three layers now live one level down.
*/
const scopeDir = () => {
const root = path.join(dataDir, 'touchpoint-content-cache');
const [only] = fs.existsSync(root) ? fs.readdirSync(root) : [];
return path.join(root, only ?? 'missing-scope');
};
const blobsDir = () => path.join(scopeDir(), 'blobs');
/** The file a digest names, so a case can damage one specific blob rather than whichever one readdir happens to list first. */
const blobFile = (value: string) => path.join(blobsDir(), value.slice('sha256:'.length));
/** The content id the assembly record on disk names right now, or `null` when there is none. */
const recordedContentId = (): string | null => {
const dir = path.join(scopeDir(), 'assemblies');
const files = fs.existsSync(dir) ? fs.readdirSync(dir) : [];
if (files.length !== 1) return null;
const record = JSON.parse(fs.readFileSync(path.join(dir, files[0] as string), 'utf8')) as {
contentId?: string;
};
return record.contentId ?? null;
};
/** `FULL_RESPONSE` promoted to the next campaign: a new activity carrying new content. */
const nextCampaign = (): typeof FULL_RESPONSE => {
const next = JSON.parse(JSON.stringify(FULL_RESPONSE)) as typeof FULL_RESPONSE;
next.activityId = 'activity-2';
next.deploymentId = 'deployment-2';
next.touchpointDecisionId = 'decision-2';
next.content.id = 'version-2';
return next;
};
/** Which content each activity is allowed to appear with. Crossing them is the defect. */
const CONTENT_OF: Record<string, string> = { 'activity-1': 'version-1', 'activity-2': 'version-2' };
describe('daemon touchpoint content assembly', () => {
it('persists native chunked full grants and shortened trimmed renewals for offline replay', async () => {
// Real Node HTTP transport: no mocked ClientRequest close/end ordering.
const now = Date.now();
let served = {
...FULL_RESPONSE,
serverTime: new Date(now).toISOString(),
startsAt: new Date(now - 60_000).toISOString(),
endsAt: new Date(now + 3_600_000).toISOString(),
authorizationExpiresAt: new Date(now + 60_000).toISOString(),
};
upstreamHandler = (_req, res, held) => {
res.setHeader('content-type', 'application/json');
if (status !== 200) { res.statusCode = status; res.end('{}'); return; }
const payload = decisionPayload(served, held);
res.write(payload.slice(0, 20));
setImmediate(() => res.end(payload.slice(20)));
};
expect((await rawDecide()).status).toBe(200);
expect(recordedContentId()).toBe('version-1');
const shortenedEnd = new Date(now + 300_000).toISOString();
served = { ...served, endsAt: shortenedEnd };
const renewed = await rawDecide();
expect(calls[1]?.heldContentId).toBe('version-1');
expect(JSON.parse(renewed.body.toString()).endsAt).toBe(shortenedEnd);
expect(calls).toHaveLength(2); // A trimmed renewal must not need a full retry.
status = 503;
const offline = await rawDecide();
expect(offline.status).toBe(200);
expect(offline.headers['x-od-touchpoint-offline']).toBe('1');
expect(JSON.parse(offline.body.toString()).endsAt).toBe(shortenedEnd);
});
// Property 1. If this ever fails, every already-published client stops seeing
// campaigns, because their guard is `if (!next.content?.id) return clear`.
it('REGRESSION: a cold daemon sends today\'s request and returns today\'s response byte for byte', async () => {
const cold = await decide();
expect(cold.status).toBe(200);
expect(calls).toEqual([
{ path: '/api/v1/touchpoints/runtime/production', heldContentId: null, heldContentLocale: null },
]);
expect(cold.text).toBe(JSON.stringify(FULL_RESPONSE));
expect(cold.text).not.toContain('contentOmitted');
});
it('asks Vela to omit content it already holds, and hands the browser the same response anyway', async () => {
const cold = await decide();
const warm = await decide();
expect(calls[1]).toEqual({
path: '/api/v1/touchpoints/runtime/production',
heldContentId: 'version-1',
heldContentLocale: LOCALE,
});
expect(warm.status).toBe(200);
// Byte for byte, which is also what guards the re-encode: blobs are stored
// as raw bytes and encoded back to base64 on the way out, so a rebuilt
// response matches the original only while upstream sends canonical
// base64. If Vela ever folds lines or switches to base64url, this is the
// assertion that says so.
expect(warm.text).toBe(cold.text);
expect(warm.text).toBe(JSON.stringify(FULL_RESPONSE));
expect(warm.text).not.toContain('contentOmitted');
// The saving is on the WAN hop only; the browser's own response is unchanged.
expect(upstreamBytes[1]).toBeLessThan(upstreamBytes[0]! / 10);
});
it('never forwards bytes its digest did not cover', async () => {
await decide();
// `SHARED` is 4121 bytes, so its base64 form ends in padding and Node's
// decoder drops anything appended after it. The digest is computed over
// that decoded view, so it goes on matching while the file -- and the
// response built from it -- carries nine bytes nobody verified.
fs.appendFileSync(blobFile(digest(SHARED)), 'GARBAGEXX');
calls = [];
const next = await decide();
expect(next.status).toBe(200);
// The daemon hands over the response it actually verified, or it asks
// again. It does not hand over one it only appeared to verify.
expect(next.text).toBe(JSON.stringify(FULL_RESPONSE));
expect(calls).toHaveLength(2);
expect(calls[1]?.heldContentId).toBeNull();
// The consequence this pins, in one line: `atob` is WHATWG
// forgiving-base64 and strips ASCII whitespace only. Any other stray
// character throws, the placement never mounts, and because the daemon
// believed it succeeded no fallback anywhere is reached.
const rebuilt = JSON.parse(next.text) as { content: { resources: Array<{ bytes: string }> } };
for (const resource of rebuilt.content.resources)
expect(() => atob(resource.bytes)).not.toThrow();
});
it('keeps serving the campaign when the cached bytes are corrupted, and stops paying for them', async () => {
await decide();
fs.writeFileSync(blobFile(digest(SHARED)), base64('tampered'));
calls = [];
const recovered = await decide();
expect(recovered.status).toBe(200);
expect(recovered.text).toBe(JSON.stringify(FULL_RESPONSE));
// One conditional ask that could not be rebuilt, then today's full request.
expect(calls).toHaveLength(2);
expect(calls[0]?.heldContentId).toBe('version-1');
expect(calls[1]?.heldContentId).toBeNull();
// Damage costs one extra round trip, not every round trip from now on. The
// full response the fallback just fetched is the daemon's only chance to
// put the real bytes back; if it declines, this placement pays double
// forever and nothing anywhere reports it.
calls = [];
const healed = await decide();
expect(healed.status).toBe(200);
expect(healed.text).toBe(JSON.stringify(FULL_RESPONSE));
expect(calls).toHaveLength(1);
expect(calls[0]?.heldContentId).toBe('version-1');
});
it('refuses before upstream dispatch when the authority root cannot be written', async () => {
const root = path.join(dataDir, 'touchpoint-content-cache');
fs.mkdirSync(root, { recursive: true });
fs.chmodSync(root, 0o500);
try {
const first = await decide();
const second = await decide();
expect(first.status).toBe(503);
expect(second.status).toBe(503);
expect(JSON.parse(first.text)).toEqual({ error: 'touchpoint_authority_unavailable' });
expect(JSON.parse(second.text)).toEqual({ error: 'touchpoint_authority_unavailable' });
expect(calls).toHaveLength(0);
expect(upstreamBytes).toHaveLength(0);
} finally {
fs.chmodSync(root, 0o700);
}
const recovered = await decide();
expect(recovered.status).toBe(200);
expect(recovered.text).toBe(JSON.stringify(FULL_RESPONSE));
expect(calls).toHaveLength(1);
expect(calls[0]?.heldContentId).toBeNull();
});
it.each(['blobs', 'modules', 'assemblies'] as const)(
'forwards valid online content when only %s writes fail after durable admission',
async (failedDirectory) => {
expect((await decide()).status).toBe(200);
const scope = scopeDir();
const journal = path.join(scope, 'replay-authority.json');
const generation = JSON.parse(fs.readFileSync(journal, 'utf8')).generation;
// Remove only reusable content so the next response must attempt writes.
// The authority journal and owner database stay writable and untouched.
for (const directory of ['blobs', 'modules', 'assemblies']) {
const contentDirectory = path.join(scope, directory);
fs.rmSync(contentDirectory, { recursive: true, force: true });
fs.mkdirSync(contentDirectory);
}
const blocked = path.join(scope, failedDirectory);
fs.chmodSync(blocked, 0o500);
calls = [];
upstreamBytes = [];
const admitted: Array<{ generation: string; pending: string[] }> = [];
upstreamHandler = (_req, res) => {
// Observe the real durable journal at the HTTP boundary, not a mock of
// begin(): every dispatched request must already carry a pending token.
admitted.push(JSON.parse(fs.readFileSync(journal, 'utf8')));
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify(FULL_RESPONSE));
};
try {
const first = await decide();
const second = await decide();
expect(first.status).toBe(200);
expect(second.status).toBe(200);
expect(first.text).toBe(JSON.stringify(FULL_RESPONSE));
expect(second.text).toBe(JSON.stringify(FULL_RESPONSE));
expect(calls).toHaveLength(2);
expect(calls.every((call) => call.heldContentId === null)).toBe(true);
expect(admitted).toHaveLength(2);
for (const admission of admitted) {
expect(admission.generation).toBe(generation);
expect(admission.pending.length).toBeGreaterThan(0);
}
expect(fs.readdirSync(blocked)).toHaveLength(0);
} finally {
fs.chmodSync(blocked, 0o700);
}
},
);
it('keeps serving the campaign against a Vela that ignores the new parameters', async () => {
await decide();
supportsTrimming = false;
const warm = await decide();
expect(calls[1]?.heldContentId).toBe('version-1');
expect(warm.text).toBe(JSON.stringify(FULL_RESPONSE));
});
it('passes an upstream refusal through untouched while holding content', async () => {
await decide();
status = 410;
errorBody = {
error: 'production_runtime_revoked',
receipt: {
touchpointDecisionId: 'decision-1',
deploymentId: 'deployment-1',
activityId: 'activity-1',
contentVersionId: 'version-1',
},
};
const revoked = await decide();
expect(revoked.status).toBe(410);
expect(revoked.text).toBe(JSON.stringify(errorBody));
status = 404;
errorBody = { error: 'no_decision' };
const missing = await decide();
expect(missing.status).toBe(404);
expect(missing.text).toBe(JSON.stringify(errorBody));
// A refusal is the server's own decision and must never be re-asked as a
// full request: the daemon's fallback is for content it cannot rebuild.
expect(calls).toHaveLength(3);
});
it('never rewrites a caller that already carries held-content parameters', async () => {
await decide();
const response = await fetch(
`${baseUrl}/api/touchpoints/production-runtime?placementKey=${PLACEMENT}&locale=${LOCALE}&heldContentId=someone-elses&heldContentLocale=fr-FR`,
);
expect(response.status).toBe(200);
expect(calls[1]).toEqual({
path: '/api/v1/touchpoints/runtime/production',
heldContentId: 'someone-elses',
heldContentLocale: 'fr-FR',
});
});
// Chromium 123+ (and so every Electron build the desktop app ships) advertises
// `zstd`, and Vela honours it. The trimmed reply is only a skeleton; if the
// daemon lets upstream pick an encoding it cannot read, it can neither see
// `contentOmitted` nor rebuild the content, and the browser is handed a
// decision with no content -- which it drops as a mismatch, so the campaign
// never shows after the first visit.
it('REGRESSION: rebuilds a trimmed reply when the browser advertises encodings the daemon cannot read', async () => {
// Node 24 ships zstd; the pinned @types/node predates it.
const zstd = zlib as typeof zlib & {
zstdCompressSync(input: Buffer): Buffer;
zstdDecompressSync(input: Buffer): Buffer;
};
const seen: Array<string | undefined> = [];
upstreamHandler = (req, res, held) => {
const accepted = String(req.headers['accept-encoding'] ?? '');
seen.push(req.headers['accept-encoding']);
const payload = Buffer.from(decisionPayload(FULL_RESPONSE, held));
res.setHeader('content-type', 'application/json');
if (/\bzstd\b/.test(accepted)) {
res.setHeader('content-encoding', 'zstd');
res.end(zstd.zstdCompressSync(payload));
} else if (/\bgzip\b/.test(accepted)) {
res.setHeader('content-encoding', 'gzip');
res.end(zlib.gzipSync(payload));
} else {
res.end(payload);
}
};
const browser = { 'accept-encoding': 'gzip, deflate, br, zstd' };
const readJson = (reply: { headers: IncomingHttpHeaders; body: Buffer }) => {
const encoding = reply.headers['content-encoding'];
const body =
encoding === 'gzip'
? zlib.gunzipSync(reply.body)
: encoding === 'zstd'
? zstd.zstdDecompressSync(reply.body)
: reply.body;
return JSON.parse(body.toString('utf8')) as Record<string, unknown>;
};
// Whichever caller first fills the store -- here one that only speaks
// gzip -- the next refresh is trimmed, and that is the reply the browser's
// own encoding preference must not make unreadable.
const cold = await rawDecide({ 'accept-encoding': 'gzip' });
const warm = await rawDecide(browser);
expect(calls[1]).toMatchObject({ heldContentId: 'version-1', heldContentLocale: LOCALE });
expect(readJson(cold)).toEqual(FULL_RESPONSE);
expect(readJson(warm)).toEqual(FULL_RESPONSE);
for (const value of seen) expect(value ?? '').not.toMatch(/zstd/);
});
// Before this route began reading bodies it forwarded them as a stream, so
// its memory was bounded by the socket. Buffering to assemble gave that up;
// these two cases put a ceiling back on it. The daemon is a privileged local
// process and `gunzipSync` is synchronous, so an unbounded expansion costs
// the whole app its event loop, not just this request.
it('refuses to expand a decision body it could never assemble', async () => {
// Valid JSON, so nothing but the size limit can reject it, and ~1000:1
// compressed, so the buffering ceiling is never the thing under test here.
const bomb = zlib.gzipSync(
Buffer.from(JSON.stringify({ ...FULL_RESPONSE, filler: 'a'.repeat(16 * 1024 * 1024) })),
);
expect(bomb.byteLength).toBeLessThan(1024 * 1024);
upstreamHandler = (_req, res) => {
res.setHeader('content-type', 'application/json');
res.setHeader('content-encoding', 'gzip');
res.end(bomb);
};
const response = await rawDecide();
// Handed back exactly as upstream framed it: still compressed, still small.
expect(response.headers['content-encoding']).toBe('gzip');
expect(response.body.byteLength).toBeLessThan(1024 * 1024);
});
it('streams a decision body too large to assemble instead of holding it', async () => {
let release!: () => void;
const released = new Promise<void>((resolve) => {
release = resolve;
});
// Past the point where the body could still be a package the browser would
// accept, so there is nothing left to assemble out of it.
const oversized = Buffer.alloc(9 * 1024 * 1024, 0x20);
upstreamHandler = (_req, res) => {
res.on('error', () => {
/* the caller may be gone by the time this finishes; that is fine */
});
res.setHeader('content-type', 'application/json');
res.write(oversized);
void released.then(() => {
try {
res.end('{}');
} catch {
/* already closed */
}
});
};
const firstChunkBytes = await new Promise<number>((resolve, reject) => {
const request = httpRequest(decisionUrl(), (response) => {
response.once('error', () => undefined);
response.once('data', (chunk: Buffer) => {
resolve(chunk.byteLength);
response.destroy();
});
});
request.on('error', reject);
request.setTimeout(5_000, () => {
request.destroy(
new Error('the daemon sent nothing to its caller before upstream finished'),
);
});
request.end();
}).finally(release);
expect(firstChunkBytes).toBeGreaterThan(0);
});
it('never splices a content version the server did not trim for', async () => {
await decide(); // Cold: the daemon now holds version-1, of activity-1.
let served = FULL_RESPONSE;
let announceArrival!: () => void;
const firstArrived = new Promise<void>((resolve) => {
announceArrival = resolve;
});
let release!: () => void;
const firstReleased = new Promise<void>((resolve) => {
release = resolve;
});
let holdNext = true;
upstreamHandler = (_req, res, held) => {
// A real server frames its reply against what it is serving at the moment
// the request lands, so the payload is decided here and not after the wait.
const payload = decisionPayload(served, held);
const answer = () => {
res.setHeader('content-type', 'application/json');
res.end(payload);
};
if (!holdNext) {
answer();
return;
}
holdNext = false;
announceArrival();
void firstReleased.then(answer);
};
// A is a live request a user is waiting on. It offers version-1 and the
// server, still serving version-1, trims its reply against exactly that.
const inFlight = decide();
await firstArrived;
// The campaign is promoted while A is still on the wire.
served = nextCampaign();
// B offers version-1 too, which no longer matches, so it gets the new
// campaign in full -- and the daemon records version-2 under this placement.
await decide();
expect(recordedContentId()).toBe('version-2');
release();
const first = JSON.parse((await inFlight).text) as { activityId: string; content: { id: string } };
// The invariant: the decision metadata and the content name one campaign.
// The browser's verifier only checks that the content is internally
// consistent, and each half is, so a crossed pair renders happily and
// reports its impressions against the other campaign's decision id.
expect(first.content.id).toBe(CONTENT_OF[first.activityId]);
});
it('drops its upstream request when the caller walks away', async () => {
await decide(); // Cold: the daemon now holds version-1.
let upstreamClosed = false;
let announceArrival!: () => void;
const requestArrived = new Promise<void>((resolve) => {
announceArrival = resolve;
});
let release!: () => void;
const released = new Promise<void>((resolve) => {
release = resolve;
});
upstreamHandler = (req, res) => {
req.socket.once('close', () => {
upstreamClosed = true;
});
res.on('error', () => {
/* the caller is gone; failing to write to it is the expected outcome */
});
announceArrival();
// Answer only once the caller has given up. The browser's per-attempt
// budget is shorter than this proxy's, so this is the ordinary case.
void released.then(() => {
try {
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify(nextCampaign()));
} catch {
/* the socket is already gone, which is the point of this case */
}
});
};
const controller = new AbortController();
const abandoned = fetch(
`${baseUrl}/api/touchpoints/production-runtime?placementKey=${PLACEMENT}&locale=${LOCALE}`,
{ signal: controller.signal },
).catch(() => null);
await requestArrived;
controller.abort();
await abandoned;
// Let the abort reach the daemon before the upstream speaks. Waiting on the
// signal rather than a fixed delay keeps the passing path at milliseconds;
// when the proxy does not drop its request there is no signal to wait for,
// so the budget runs out and the assertions below say why.
await vi
.waitFor(
() => {
expect(upstreamClosed).toBe(true);
},
{ timeout: 2_000, interval: 10 },
)
.catch(() => undefined);
// A real upstream has no idea the browser gave up. It answers regardless.
release();
await new Promise((resolve) => {
setTimeout(resolve, 200);
});
expect(upstreamClosed).toBe(true);
// Nobody reads an orphan's response; its side effects are the whole
// problem. A reply that outlived its caller must not rewrite the record a
// later, live request is about to rebuild from.
expect(recordedContentId()).toBe('version-1');
});
it('follows the server to a new activity within one poll instead of pinning the cached one', async () => {
await decide();
const replacement = JSON.parse(JSON.stringify(FULL_RESPONSE)) as typeof FULL_RESPONSE;
replacement.activityId = 'activity-2';
replacement.deploymentId = 'deployment-2';
replacement.content.id = 'version-2';
supportsTrimming = false;
await close(upstream);
upstream = createServer((req, res) => {
const url = new URL(req.url ?? '/', 'http://upstream');
calls.push({
path: url.pathname,
heldContentId: url.searchParams.get('heldContentId'),
heldContentLocale: url.searchParams.get('heldContentLocale'),
});
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify(replacement));
});
// The daemon resolves Vela per request, so rebinding the same port is not
// required: re-register against the new address.
const address = await listen(upstream);
await close(daemon);
const app = express();
app.use(express.json());
const lifecycle = registerVelaRoutes(app, {
paths: { RUNTIME_DATA_DIR: dataDir },
appConfig: { readAppConfig: async () => ({ agentCliEnv: {} }) as AppConfigPrefs },
http: {},
env: { VELA_CONTROL_KEY: 'ck-test', VELA_API_URL: `http://127.0.0.1:${address.port}` },
});
daemon = createServer(app);
lifecycles.set(daemon, lifecycle);
const daemonAddress = await listen(daemon);
baseUrl = `http://127.0.0.1:${daemonAddress.port}`;
const next = await decide();
expect(JSON.parse(next.text)).toEqual(replacement);
});
});