Skip to content

Commit a39389e

Browse files
fix(service-messaging)!: an html or mjml notification template escapes every hole for markup, and each outlet package pins its outlet list (ruling 3C) (#25076)
Fixes #25061 Clause-②: no (narrowing) Carries out ruling **3C** on #25040 (`6308462370`): no server-side stripping or refusal of markup in text fields; every outlet that renders a text field into HTML escapes it by context, the mail template engine by default, with a pin on the outlet list. Disclosure: the #9387 ruling (`5323310017`) applies to this PR. Fixtures are neutral markup (a bold or italic element, an ampersand, quotes), and nothing here describes a request. ## What changed - **`@objectstack/service-messaging` (the one outlet that did not escape).** `renderNotification` rendered a `sys_notification_template` body of format `html` or `mjml` by placing each `{{ payload.x }}` value into the markup as stored. The email channel sends that string as the mail's html part, so a payload text field carrying markup characters (a display name, a record title, a comment preview) reached the recipient's mail client as markup. Each hole's value in an html or mjml body is now HTML-escaped, in element text and quoted attributes alike; the template's own markup renders as written. The subject and a `markdown` or `text` body are plain text and stay verbatim. No unescaped hole form is added (none existed, and nothing in the tree needs one); a template that needs a pre-rendered fragment uses an email template, whose renderer has one. - **Pins, in three NEW files named by the rule** (`src/text-field-html-outlets.pin.test.ts` in each package that holds an outlet): one pin on the outlet list itself, and per-entry pins that drive the real render path. - **Changeset:** `.changeset/25061-notification-template-html-escape.md`, `@objectstack/service-messaging` minor, BREAKING (patch round 1; it was a patch before the seat ruled question 1 B). Nothing was edited in `packages/spec`, `packages/objectql` or `packages/platform-objects`. ## Outlet census The census is the AST walk each pin file carries: a shipped source file "touches HTML" when an identifier or a string in its CODE names `html`, or a string carries an HTML element tag (comments never count). I ran the same walk over every `domain:services` package (`packages/services/*/src`, `packages/connectors/*/src`, `packages/triggers/*/src`, `packages/plugins/*/src`) at `c10cc2a71b`, seeded by `git grep` for escaper helpers, `text/html`, `body_html` and HTML tags next to interpolation. Every hit is classified below. | Package | File | Role | Escapes by context? | |:--|:--|:--|:--| | plugin-email | `template-engine.ts` | outlet: the engine. `renderTemplate` is the HTML face, `renderPlainTextTemplate` the plain-text faces | yes, by default, unchanged: a double-braced hole is HTML-escaped in `body_html` and verbatim in the subject and `body_text`; the triple-braced hole is the one unescaped form | | plugin-email | `email-service.ts` | outlet: `resolveAndRenderTemplate`, behind `sendTemplate` and `renderTemplate` | yes: `body_html` goes through the HTML face | | plugin-email | `templates/auth-templates.ts` | outlet: the built-in auth templates | yes: every text variable is double-braced; see the raw-form reading below | | plugin-email | `index.ts`, `template-loader.ts`, `transports/smtp.ts`, `transports/resend.ts`, `transports/postmark.ts` | not an outlet: re-export, column mapping, and handing the rendered html part to the provider | n/a | | service-messaging | `template-renderer.ts` | outlet: `renderNotification` for an html or mjml body | **was no; fixed here** | | service-messaging | `email-channel.ts` | not an outlet: forwards the rendered html; its template arm delegates to plugin-email's engine | n/a | | service-messaging | `objects/notification-template.object.ts` | not an outlet: declares the `format` vocabulary | n/a | | plugin-approvals | `action-link-pages.ts` | outlet: the session-less confirm and result pages an approval link opens | yes, unchanged: every request text field goes through its escaper | | plugin-approvals | `approval-service.ts`, `approvals-plugin.ts` | not an outlet: wrap or serve that rendered page as `text/html` | n/a | | service-mailbox | `ingest/mail-ingest-service.ts`, `ingest/parse-message.ts`, `objects/sys-mail-message.object.ts` | not an outlet: stores the html part of INBOUND mail; renders nothing | n/a | - **HTML exports:** none on the server. Record exports are csv, xlsx and json (`packages/rest/src/export-format.ts`). The print service renders the console's own record page in a headless browser, so the UI renders the value there; the server puts no field value into markup. - **Integration payloads:** no builder emits HTML. Webhooks serialize JSON. `connector-slack` posts caller-authored text verbatim, in Slack's markup, not HTML. `service-sms` sends plain text. The messaging digest renders plain text. - **Other lanes (named for the seat, not touched):** `packages/cli/src/commands/serve.ts` (host-not-found page) escapes its one value. `packages/plugins/plugin-hono-server/src/adapter.ts` carries an already-rendered page. One further out-of-lane HTML builder, outside ruling 3C's text-field scope, is named to the seat privately. The UI renderer lives in objectui. ## The engine's raw form, measured - **What exists.** `plugin-email`'s engine has exactly one unescaped form, the triple-braced hole. `service-messaging`'s renderer had none (every hole was unescaped), and it still has none. - **Who uses it.** The built-in auth templates have 20 sites: password reset, verify email, magic link, invitation accept and account-invitation set-password, times four locales. Each one names a `type: 'url'` variable inside an `href` attribute, for a link the platform minted. The only other uses are plugin-email's own tests and the authoring doc (`content/docs/automation/email-templates.mdx`). `examples/app-showcase`'s authored template uses double braces only. - **Kept, and pinned.** The built-ins keep that raw form. The new pin holds them to it: in every built-in body, each unescaped hole must name a declared url variable and sit inside an `href` attribute. Every text variable must render escaped through the real `EmailService`. ## `sys_user.name` maxLength: reading The declaration, at base `4bf079c693`, is `Field.text`, `required`, `maxLength: 255` (`packages/platform-objects/src/identity/sys-user.object.ts:776-781`). I measured it on a real ObjectQL with SqlDriver (in-memory sqlite) under the system context, which is the context the auth adapter writes `sys_user` with (`asAttributedSystem` in plugin-auth's `objectql-adapter.ts`). I used a scratch test that is not committed. | write | answer | |:--|:--| | insert, name of 255 characters (control) | accepted, 255 stored | | insert, name of 256 characters | refused `VALIDATION_FAILED`, field error `max_length`, constraint maxLength 255 / actual 256 | | update an existing row's name to 256 characters | refused `VALIDATION_FAILED`; the stored value is unchanged | **Reading:** enforced at the write seam, by the objectql write validator's bounded-string branch, which is #11875's rule (`text` is in `BOUNDED_STRING_FIELD_TYPES`). No edit is owed in `platform-objects` or `objectql`. ## Pins and ablation There is one leg per pinned behaviour. Each mutation went through `scripts/ablation-replace.mjs` in wrap mode (the anchor must hit once; landing is proven by on-disk counts and a blob change). Each restore is `git checkout HEAD -- PATH`, proven by restored blob equal to the HEAD blob and an empty `git diff HEAD`. No mutation needs a rebuild: every pin imports its subject from `src`. | leg | mutation (on disk) | reading on the pin file | |:--|:--|:--| | A | service-messaging html face encodes verbatim | red, 3 failed of 8: html, mjml, email-channel path. The subject and plain-text pins stay green, as they should | | B | an unlisted service-messaging file gains an html-named export | red, 1 failed of 8: the outlet-list pin | | C | plugin-email `renderTemplate` encodes verbatim | red, 32 failed of 66: engine, email-service and every built-in escape pin | | D | the en-US default footer's app-name hole becomes triple-braced | red, 10 failed of 66: raw-hole placement and escape, for the five en-US templates on that footer | | E | an unlisted plugin-email file gains an html-named export | red, 1 failed of 66: the outlet-list pin | | F | approval summary rows stop escaping | red, 2 failed of 5: confirm and result pages | | G | an unlisted plugin-approvals file gains an html-named export | red, 1 failed of 5: the outlet-list pin | Legs B, E and G first ran as no-ops: each replacement contained its own anchor, so `ablation-replace` refused the leg as not landed and restored before any test ran. They were re-run with replacements that do not contain the anchor (B2, E2, G2), and the readings above are from those re-runs. All legs ran at `c10cc2a71b`, and every restore was proven. ## Tests and gates (final head `1960b459bb`) Every run went under `scripts/pm/os-verify-lock.sh`, after the closure build (`pnpm --filter 'PKG^...' --filter PKG build` for the three packages; exit 0). | command | head | result | |:--|:--|:--| | `pnpm --filter @objectstack/service-messaging test` | `1960b459bb` | exit 0, Test Files 55/55, Tests 610/610 | | `pnpm --filter @objectstack/service-messaging typecheck` | `1960b459bb` | exit 0; the tsc program lists the new pin file (`--listFiles` count 1) | | `pnpm --filter @objectstack/plugin-email test` | `73f7bf8fe5` | exit 0, Test Files 36/36, Tests 643/643 | | `pnpm --filter @objectstack/plugin-email typecheck` | `73f7bf8fe5` | exit 0; `check:test-typecheck: OK`, with 0 errors held and the new file in the test program (count 1) | | `pnpm --filter @objectstack/plugin-approvals test` | `73f7bf8fe5` | exit 0, Test Files 94/94, Tests 1213/1213 | | `pnpm --filter @objectstack/plugin-approvals typecheck` | `73f7bf8fe5` | exit 0; `check:test-typecheck: OK` (ledger unchanged, the new file is in the test program, count 1) | The only commit after `73f7bf8fe5` is `1960b459bb`, which edits the service-messaging pin file alone. Its package re-ran on that head, and so did the ablation legs that read the file (A and B, again 3/8 and 1/8 red). **Derived gate families.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 68 commands. All 68 ran, each with its exit captured before any pipe, and `--ran` reconciles **68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN** (a derived zero: every line records an exit code). - On the first pass at `73f7bf8fe5`, 65 exited 0. - `check:engine-double-contract` went red there, because the first draft of the channel-path pin added a new engine double. The pin now hands the channel a stored-row store instead, and the gate is exit 0 at `1960b459bb`. - `check:i18n` and `check:dual-build-cjs-loads` exited 3 (prerequisite not met: no full build). After a full build, both are exit 0 at `1960b459bb`. - The ratchet families were re-run at `1960b459bb`, all exit 0: `check:engine-double-contract`, `check:cross-package-test-inputs`, `check:test-source-alias`, `check:type-check-coverage`, `check:type-check-debt`, `check:objectql-double-limit` and `check:nul-bytes`. The repo-wide scans and the CI-only lanes (Test Core shards, Build Core, the type-check lanes, `test:repo`) are CI's. I did not re-run them locally. **Dogfood battery:** ~~n/a. The title has no `!` and the claim has no `(narrowing)` arm (see open question 1).~~ Superseded by patch round 1: the claim moved to `(narrowing)`, and the full battery ran on head `7aeddce6f3` (see below). ## Acceptance notes - The list pin's radius is per package. Each package that holds an outlet today carries its own census, and the census reads only that package's source, so no cross-package input declaration is needed. A new outlet in a package with no list is not seen. The alternatives are in the open questions. - `service-messaging`'s html face has no unescaped hole by design. The migration line in the changeset names the email-template route for a pre-rendered fragment. - These new pin files each scan their own package with the `typescript` parser, which is already a devDependency of all three. No gate, script or `turbo.json` entry was added. ## Open questions for the seat 1. **Clause-②: does the html-face escaping count as a narrowing?** It changes what an existing html or mjml `sys_notification_template` row renders when a hole's value carries an ampersand, an angle bracket or a quote. That includes a row that relied on a payload value rendering as markup. Measured reach in this tree at `73f7bf8fe5`: - authored html or mjml notification-template rows: 0 (no seed, no example, 0 rows in the dogfood upgrade fixtures `fixture-18.0` and `fixture-18.0.0`, and no `format: 'html'` or `'mjml'` outside `packages/spec`); - producers that put pre-rendered HTML into a payload: 0 of the 3 messaging producers (approvals `notify`, the audit mention writer, the automation notify node); - rows stored in deployments: not measurable from here. The PR is declared `Clause-②: no` as claimed. If the seat amends the claim to `no (narrowing)`, this PR owes a `!` title, a BREAKING changeset with its ADR-0087 disposition, and the full dogfood battery on the final head. 2. **The list pin's radius.** Options: - (A) per package, as landed here; - (B) one lane-wide census test, which needs a cross-package input declaration in `scripts/cross-package-test-inputs.mjs` and `turbo.json`, outside this claim's file surface; - (C) a repo-level census in another lane's `repo` vitest project. 3. **One out-of-lane HTML builder** (outside ruling 3C's text-field scope) is named to the seat privately, for the seat to declare cross-lane. Its reach is not measured. ## Patch round 1 - Patch round 1: the changeset now ships as a BREAKING minor (`fix(service-messaging)!`, `Clause-②: no (narrowing)`, ADR-0087 `not-required (no-migration-prescription)`), head `7aeddce6f3`. - Full dogfood battery on head `7aeddce6f3`: VERDICT command-exit 0, Test Files 286 passed / 3 skipped (289), Tests 2387 passed / 18 skipped (2405), 0 failed. --- _Generated by [Claude Code](https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f46ef4d commit a39389e

5 files changed

Lines changed: 839 additions & 2 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/service-messaging': minor
3+
---
4+
5+
fix(service-messaging)!: an html or mjml `sys_notification_template` body escapes every hole's value for markup
6+
7+
The email channel renders a notification template row whose `format` is `html` or `mjml` into the mail's html part. Its `{{ payload.x }}` holes placed each value into that markup as stored, so a text field carrying markup characters — a person's display name, a record title, a comment — was read as markup by the recipient's mail client. Each hole's value in an html or mjml body is now HTML-escaped, in element text and quoted attributes alike, and the template's own markup renders as written. Stored text is never stripped or refused: a display name may contain any character, and the mail client shows it as typed.
8+
9+
Clause-②: no (narrowing)
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. An authored template body's own markup renders exactly as before; what changes is how the renderer encodes a hole's VALUE in the html and mjml faces. The remedy is an author's choice — put the markup in the template body instead of a payload value — never a rewrite of a stored row or a package source, and stored `sys_notification_template` rows are not touched. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
12+
13+
**BREAKING** (what an html or mjml notification template renders for a markup-bearing value narrows), shipped as `minor` under the launch-window convention for breaking changes.
14+
15+
- **FROM:** in an html or mjml `sys_notification_template` body, a `{{ payload.x }}` hole rendered its value verbatim, so a value carrying markup became markup in the sent html part.
16+
- **TO:** the same hole renders the value HTML-escaped: an ampersand, an angle bracket or a quote arrives as the matching HTML entity, which a mail client displays as the original character.
17+
- **Unchanged:** the subject and a `markdown` or `text` body are plain text and keep rendering values verbatim. A value without markup characters renders byte-identically. The email-template path (`sys_email_template`, named in a notify step's `template`) already escaped its html body by default and is unchanged, and no stored row is rewritten.
18+
- **What to do:** a template that relied on a payload value rendering as markup moves that markup into the template body, or into an email template, whose triple-braced hole is the one unescaped form.
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Every outlet in this package that renders a text field into HTML escapes it
5+
* by context — ruling 3C on #25040.
6+
*
7+
* ## The rule
8+
*
9+
* A text field is stored as the person typed it: a display name may contain
10+
* any character, so nothing is stripped or refused at the write. The burden
11+
* sits on the OUTLET — the code that places a value into markup — and an
12+
* outlet escapes by default, for the context the value lands in.
13+
*
14+
* ## The list, and the pin on the list itself
15+
*
16+
* {@link OUTLETS} names every file of this package that renders a value into
17+
* HTML, each with pins that drive the real render path. A per-entry pin cannot
18+
* see an outlet nobody listed, so the LIST is pinned too: the census walks this
19+
* package's source and finds every file that touches HTML at all — an
20+
* identifier or a string naming `html`, or a string carrying an HTML tag — and
21+
* requires each one to be on {@link OUTLETS} or on {@link NOT_OUTLETS} with the
22+
* reason it renders nothing. A new file that touches HTML fails here until its
23+
* author says which it is.
24+
*
25+
* ## When the census fires
26+
*
27+
* ⛔ Not by adding the file to {@link NOT_OUTLETS} because it is in the way. If
28+
* the file places a value into markup it is an outlet: escape the value for its
29+
* context, add the entry to {@link OUTLETS} and give it a pin that renders a
30+
* text field carrying markup characters through the real path. Only a file that
31+
* moves already-rendered HTML, or names HTML as vocabulary, is a non-outlet,
32+
* and its entry says which.
33+
*
34+
* ⚠️ LIMIT, stated so it is not read as more: the census reads source text of
35+
* THIS package. It proves no unlisted file here touches HTML; it does not see
36+
* another package (each package holding an outlet carries its own list), and it
37+
* does not prove a listed outlet escapes — that is what the per-entry pins are
38+
* for.
39+
*/
40+
41+
import { readFileSync, readdirSync, statSync } from 'node:fs';
42+
import { dirname, join, relative, sep } from 'node:path';
43+
import { fileURLToPath } from 'node:url';
44+
45+
import ts from 'typescript';
46+
import { describe, it, expect } from 'vitest';
47+
import type { ApprovalRequestRow } from '@objectstack/spec/contracts';
48+
49+
import { renderConfirmPage, renderResultPage } from './action-link-pages.js';
50+
51+
/** …/packages/plugins/plugin-approvals/src — this package's whole source tree. */
52+
const SRC = dirname(fileURLToPath(import.meta.url));
53+
54+
/** A file that renders a value into HTML, and the pin that holds it to the rule. */
55+
interface OutletEntry {
56+
/** What it renders. */
57+
readonly renders: string;
58+
/** Registers the pins that drive its real render path — an outlet cannot be listed without one. */
59+
readonly pin: () => void;
60+
}
61+
62+
/** A file that touches HTML without rendering a value into it. */
63+
interface NotOutletEntry {
64+
readonly why: string;
65+
}
66+
67+
const OUTLETS: Readonly<Record<string, OutletEntry>> = {
68+
'action-link-pages.ts': {
69+
renders: 'the session-less confirm and result pages an approval link opens',
70+
pin: pinActionLinkPages,
71+
},
72+
};
73+
74+
const NOT_OUTLETS: Readonly<Record<string, NotOutletEntry>> = {
75+
'approval-service.ts': {
76+
why: 'wraps a page action-link-pages.ts rendered in a text/html Response; renders no value itself',
77+
},
78+
'approvals-plugin.ts': {
79+
why: 'serves a page action-link-pages.ts rendered with a text/html content type; renders no value itself',
80+
},
81+
};
82+
83+
// ── The census ──────────────────────────────────────────────────────────────
84+
85+
const HTML_WORD = /html/i;
86+
/** An HTML element opening or closing tag inside a string. */
87+
const HTML_TAG =
88+
/<(?:!doctype|\/?(?:html|head|body|title|meta|style|script|link|p|a|b|i|u|em|strong|small|span|div|br|hr|img|table|thead|tbody|tfoot|tr|td|th|ul|ol|li|h[1-6]|form|input|button|textarea|select|option|label|pre|code|blockquote|section|article|header|footer|nav|main|center|font|iframe|svg|mj-[a-z-]+))(?=[\s>/])/i;
89+
90+
/** Does this source touch HTML in CODE — comments never count. */
91+
function touchesHtml(fileName: string, text: string): boolean {
92+
const source = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true);
93+
let hit = false;
94+
const visit = (node: ts.Node): void => {
95+
if (hit) return;
96+
if (ts.isIdentifier(node) || ts.isPrivateIdentifier(node)) {
97+
if (HTML_WORD.test(node.text)) hit = true;
98+
} else if (
99+
ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)
100+
|| ts.isTemplateHead(node) || ts.isTemplateMiddle(node) || ts.isTemplateTail(node)
101+
) {
102+
if (HTML_WORD.test(node.text) || HTML_TAG.test(node.text)) hit = true;
103+
}
104+
if (!hit) ts.forEachChild(node, visit);
105+
};
106+
visit(source);
107+
return hit;
108+
}
109+
110+
/**
111+
* Every shipped source file, package-relative. Tests and test kits ship
112+
* nothing; the generated translation bundles are projections of the object
113+
* declarations (`check:i18n` owns them) and render nothing.
114+
*/
115+
function shippedSources(dir = SRC): string[] {
116+
const out: string[] = [];
117+
for (const entry of readdirSync(dir)) {
118+
const full = join(dir, entry);
119+
if (statSync(full).isDirectory()) {
120+
out.push(...shippedSources(full));
121+
continue;
122+
}
123+
if (!entry.endsWith('.ts') || entry.endsWith('.d.ts')) continue;
124+
if (entry.endsWith('.test.ts') || entry.endsWith('.testkit.ts')) continue;
125+
const rel = relative(SRC, full).split(sep).join('/');
126+
if (rel.startsWith('translations/') && entry.endsWith('.generated.ts')) continue;
127+
out.push(rel);
128+
}
129+
return out.sort();
130+
}
131+
132+
describe('the outlet list — every file here that touches HTML is classified', () => {
133+
it('the census reads code, not comments (control)', () => {
134+
expect(touchesHtml('a.ts', '// renders html\nexport const x = 1;')).toBe(false);
135+
expect(touchesHtml('a.ts', 'export const x = `<p>${1}</p>`;')).toBe(true);
136+
expect(touchesHtml('a.ts', 'export const html = 1;')).toBe(true);
137+
expect(touchesHtml('a.ts', "export const t = 'text/html';")).toBe(true);
138+
});
139+
140+
it('the files that touch HTML are exactly the listed outlets and non-outlets', () => {
141+
const touching = shippedSources().filter((rel) => touchesHtml(rel, readFileSync(join(SRC, rel), 'utf8')));
142+
const listed = [...Object.keys(OUTLETS), ...Object.keys(NOT_OUTLETS)].sort();
143+
expect(touching).toEqual(listed);
144+
});
145+
146+
it('no file is both an outlet and a non-outlet', () => {
147+
expect(Object.keys(OUTLETS).filter((f) => f in NOT_OUTLETS)).toEqual([]);
148+
});
149+
});
150+
151+
// ── The per-entry pins ──────────────────────────────────────────────────────
152+
153+
/** A text field as a person may type it: markup characters, kept as typed. */
154+
const PERSON = 'Ada <b>Lovelace</b> & "Co" it\'s';
155+
const PERSON_IN_HTML = 'Ada &lt;b&gt;Lovelace&lt;/b&gt; &amp; &quot;Co&quot; it&#39;s';
156+
/** The fixture's markup as it would appear had a value passed through unescaped. */
157+
const PERSON_MARKUP = '<b>Lovelace</b>';
158+
159+
const REQUEST: ApprovalRequestRow = {
160+
id: 'apr_1',
161+
process_name: 'expense_approval',
162+
process_label: PERSON,
163+
object_name: 'expense',
164+
object_label: PERSON,
165+
record_id: 'exp_1',
166+
status: 'pending',
167+
current_step: 'manager',
168+
step_label: PERSON,
169+
submitter_id: 'usr_1',
170+
submitter_name: PERSON,
171+
};
172+
173+
function pinActionLinkPages(): void {
174+
it('the confirm page escapes every request text field, the acting address and the form attributes', () => {
175+
const page = renderConfirmPage({
176+
request: REQUEST,
177+
action: 'approve',
178+
approverId: PERSON,
179+
token: PERSON,
180+
actPath: '/api/v1/approvals/act',
181+
});
182+
183+
// Four request fields, the acting address, and the hidden token's value attribute.
184+
expect(page.split(PERSON_IN_HTML).length - 1).toBe(6);
185+
expect(page).toContain(`value="${PERSON_IN_HTML}"`);
186+
expect(page).not.toContain(PERSON_MARKUP);
187+
});
188+
189+
it('the result page escapes every request text field', () => {
190+
const page = renderResultPage('approved', REQUEST);
191+
192+
expect(page.split(PERSON_IN_HTML).length - 1).toBe(4);
193+
expect(page).not.toContain(PERSON_MARKUP);
194+
});
195+
}
196+
197+
// Registered last, so every fixture above is initialised before a pin reads it.
198+
for (const [file, entry] of Object.entries(OUTLETS)) {
199+
describe(`${file} — ${entry.renders}`, entry.pin);
200+
}

0 commit comments

Comments
 (0)