Skip to content

Commit dca6f5f

Browse files
zaridanclaude
andcommitted
test(dev): seed one inbound HTML conversation to exercise the UI sanitizer path
The dev API seed (src/dev/seed.ts) only carried plain-text bodies, so the Agent Inbox UI's sanitized-HTML path (DOMPurify container, the "HTML email · sanitized · external images blocked" caption, the Show-original modal) was never fed real data in local dev — the design prototype's mock deliberately seeded an inbound <script> + tracking-pixel <img> for exactly this reason. Adds one inbound-only conversation whose thread carries a realistic bodyHtml (formatting + a link + a remote <img> + a <script>) alongside its bodyText, mirroring fixtures/mail/observed/html-body.json and spec §5's stored-XSS contract (the store returns untrusted HTML verbatim; the renderer sanitizes). Inbound-only, so it adds a conversation without perturbing the delivery-state distribution the test asserts. Verified: unit test green; booted `dev:api` and confirmed the API serves the HTML body verbatim (script/img/link/formatting intact) alongside bodyText. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b70fc79 commit dca6f5f

2 files changed

Lines changed: 58 additions & 5 deletions

File tree

src/dev/seed.test.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,16 +48,17 @@ describe('seedDevData', () => {
4848
supportAddress: SUPPORT_ADDRESS,
4949
})
5050

51-
expect(result.conversationCount).toBe(6)
51+
expect(result.conversationCount).toBe(7)
5252

5353
// Real sends went through the injected sender for every reply EXCEPT the
5454
// one deliberately routed through the seed's own failing sender — see
55-
// seed.ts's "failed" demo.
55+
// seed.ts's "failed" demo. The HTML demo is inbound-only (no reply), so
56+
// it adds a conversation without adding a send.
5657
expect(sent.length).toBe(4)
5758

5859
const open = await store.listConversations({ limit: 50, folder: 'open' })
5960
const closed = await store.listConversations({ limit: 50, folder: 'closed' })
60-
expect(open).toHaveLength(5)
61+
expect(open).toHaveLength(6)
6162
expect(closed).toHaveLength(1)
6263

6364
const allThreads = await Promise.all(
@@ -83,5 +84,19 @@ describe('seedDevData', () => {
8384
.find((t) => t.deliveryStatus === 'pending')
8485
expect(pendingThread).toBeDefined()
8586
expect(Date.now() - (pendingThread?.createdAt.getTime() ?? 0)).toBeGreaterThan(5 * 60_000)
87+
88+
// The HTML demo exists to exercise the inbox UI's sanitized-HTML path, so
89+
// its inbound thread must actually carry a bodyHtml, stored verbatim
90+
// (spec §5: the store returns untrusted HTML as-is; the renderer sanitizes)
91+
// alongside a plain-text alternative. Assert the four things the UI must
92+
// handle survived storage: formatting, a link, a remote <img>, a <script>.
93+
const htmlThread = allThreads.flatMap((c) => c?.threads ?? []).find((t) => t.bodyHtml !== null)
94+
expect(htmlThread).toBeDefined()
95+
expect(htmlThread?.direction).toBe('inbound')
96+
expect(htmlThread?.bodyText).not.toBeNull()
97+
expect(htmlThread?.bodyHtml).toContain('<strong>')
98+
expect(htmlThread?.bodyHtml).toContain('<a href=')
99+
expect(htmlThread?.bodyHtml).toContain('<img ')
100+
expect(htmlThread?.bodyHtml).toContain('<script>')
86101
})
87102
})

src/dev/seed.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
* boot for the upcoming Agent Inbox UI (HT-23) to exercise every state it
44
* must render: an inbound-only conversation, a threaded back-and-forth, one
55
* outbound thread in each delivery state (`sent`/`failed`/stale `pending`),
6-
* and a closed conversation. Every name and message below is invented for
7-
* this seed — never real customer data (CLAUDE.md).
6+
* a closed conversation, and one inbound message with a rich HTML body (so
7+
* the UI's sanitized-HTML path — spec §5's stored-XSS contract — is exercised
8+
* against real data). Every name and message below is invented for this
9+
* seed — never real customer data (CLAUDE.md).
810
*
911
* Reuses the real engine paths wherever practical rather than raw SQL, so
1012
* seeding itself exercises the store and the send pipeline:
@@ -272,5 +274,41 @@ export async function seedDevData(deps: SeedDevDataDeps): Promise<SeedDevDataRes
272274
}
273275
await store.setConversationStatus(closedDemo.conversationId, 'closed')
274276

277+
// --- 7. Inbound-only with a rich HTML body. --------------------------------
278+
// The one demo that exercises the inbox UI's sanitized-HTML path (spec
279+
// §5's stored-XSS contract). The parser stores inbound HTML verbatim —
280+
// `<script>` and all (specs/mail/threading.md §5, fixtures/mail/observed/
281+
// html-body.json) — so this `bodyHtml` deliberately carries the four
282+
// things the UI's sanitizer, its "HTML email · sanitized · external images
283+
// blocked" caption, and its Show-original modal must all handle: formatting,
284+
// a link, a remote `<img>` (a tracking pixel), and a `<script>`. The store
285+
// returns it untouched (safe as JSON); sanitization is the renderer's job.
286+
// `bodyText` is the plain-text alternative the same mail would carry.
287+
await store.createConversation({
288+
subject: 'Unexpected charge on my March invoice',
289+
customerEmail: 'noah.feldman@example.test',
290+
firstMessage: {
291+
direction: 'inbound',
292+
messageId: '<inbound-1@noah-feldman.example.test>',
293+
fromAddress: 'noah.feldman@example.test',
294+
bodyText:
295+
'Hi there,\n\n' +
296+
"My March invoice shows a charge I don't recognize — a line item for " +
297+
'"Priority Support" that I never signed up for.\n\n' +
298+
'Here is the invoice in question: https://billing.example.com/invoices/48213\n\n' +
299+
'Could you take a look and let me know? Thanks,\nNoah',
300+
bodyHtml:
301+
'<p>Hi there,</p>' +
302+
'<p>My <strong>March invoice</strong> shows a charge I don&rsquo;t recognize &mdash; ' +
303+
'a line item for <em>&ldquo;Priority Support&rdquo;</em> that I never signed up for.</p>' +
304+
'<p>Here is the invoice in question: ' +
305+
'<a href="https://billing.example.com/invoices/48213">billing.example.com/invoices/48213</a></p>' +
306+
'<p>Could you take a look and let me know? Thanks,<br>Noah</p>' +
307+
'<img src="https://tracker.example.com/o.gif?u=48213" width="1" height="1" alt="">' +
308+
'<script>document.title = "pwned"</script>',
309+
},
310+
})
311+
conversationCount++
312+
275313
return { conversationCount }
276314
}

0 commit comments

Comments
 (0)