Files
redefined-designs/backend/tests/unit/emailTemplates.test.ts
synAdminandClaude Opus 5 90e372d6bd
Linting / lint (pull_request) Successful in 3m37s
SonarQube Analysis / sonarqube (pull_request) Failing after 35m39s
feat(admin): move a customer's account to an address they can reach (#337)
The third step of the only recovery route a customer who has lost their mailbox has. The first two are contacting the shop and being verified against order history. The third had no implementation, so the answer was a hand-written database edit that left no record of who did it or why.

The thing to say plainly, because everything here follows from it: this operation and an account takeover are the same operation. They differ only in whether the verification was sound, and nothing in the software can check that. What the software can do is make the change recorded, announced, and complete in its effects.

Recorded. The endpoint refuses without a written reason, and the reason is stored against the account. That row is the only thing that tells a genuine recovery from a takeover afterwards, which is why a hand edit was never acceptable and why the field is required by the server rather than merely collected by the form. It is never shown to the customer: it is a note about how somebody was verified and can name things the customer should not be handed back.

There is no column for who did it. Admin access is one shared gate secret in front of a single operator, so such a column could only ever hold a constant, and a constant dressed up as an identity is worse than an honest absence.

Announced, to the address being replaced. If the recovery was sound that reaches nobody and costs nothing. If it was not, it reaches the real owner, who is the only person in the world who can say so, and that is the only reason this endpoint is safe to have at all. Its own template rather than the self-service one, because that copy says to contact us if you did not make this change, and here somebody already did — the sentence would be addressed to the customer who just did the thing it asks for, while the person who needs to act on it did nothing.

The new address is marked unverified and sent a confirmation link. Somebody reading an address out over the phone has not demonstrated they can receive mail at it, and that is the commonest way this goes wrong harmlessly.

Complete in its effects. The move signs the customer out everywhere, removes every passkey, and cancels reset links already sent. That is the conclusion #42 reached for password reset, and it applies here with more force: somebody the system cannot identify asked for this change, so a session or a credential surviving it is one the new owner cannot see and cannot revoke, and a reset link sitting in the mailbox being taken away would let whoever still reads it take the account straight back.

The password is left alone. What the customer lost was the mailbox, so demanding a new one adds a step for no gain.

The verification-email helper moved out of the customers route into its own module, for the reason session creation moved out for passkeys: two implementations that agree today are two that can be changed one at a time, and the one that gets forgotten is whichever the manual testing does not exercise. This path runs perhaps once a year, so it is exactly the one that would rot.

The admin drawer gains the action next to the address rather than among the account controls, because it is a thing done to that field by someone already looking at it. It leads with the warning instead of burying it. The history of moves sits on the same drawer and renders nothing at all for the overwhelming majority of customers, who have never been moved.

Verified: backend tsc clean for src and tests, 526 unit tests pass, lint clean apart from warnings that predate this branch; frontend tsc, lint and build clean. The integration suite needs a database this machine has no Docker for. It also cannot be proven by CI right now — run 917 has been hung since it started and 24 runs are queued behind it, which is the same hang #154 identifies as the source of the leftover Postgres containers.

Closes #337

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:36:34 -05:00

324 lines
12 KiB
TypeScript

import {
TEMPLATES,
TemplateKey,
missingPlaceholders,
renderTemplate,
formatDuration,
greeting,
SAMPLE_VALUES
} from '../../src/emailTemplates';
// Derived from TEMPLATES rather than hardcoded, so a new template is covered
// by every it.each below the moment it is added. A hardcoded list silently
// stops covering anything added after it was written — which is exactly how
// intakeDraft and uploadLink went untested by the SAMPLE_VALUES guard below.
const KEYS = Object.keys(TEMPLATES) as TemplateKey[];
describe('the built-in templates', () => {
it.each(KEYS)('%s has a default subject and body', (key) => {
expect(TEMPLATES[key].defaultSubject.trim()).not.toBe('');
expect(TEMPLATES[key].defaultBody.trim()).not.toBe('');
});
// A default that would be refused on save is a default nobody can edit and
// put back.
it.each(KEYS)('%s default body satisfies its own required placeholders', (key) => {
expect(missingPlaceholders(key, TEMPLATES[key].defaultBody)).toEqual([]);
});
});
describe('missingPlaceholders', () => {
it('names the placeholder a body has dropped', () => {
expect(missingPlaceholders('passwordReset', 'Hello, no link here.')).toEqual(['resetUrl']);
});
it('is satisfied once the placeholder is present', () => {
expect(missingPlaceholders('passwordReset', 'Reset it [here]({{resetUrl}}).')).toEqual([]);
});
it('reports every missing placeholder rather than the first', () => {
const missing = missingPlaceholders('cartReminder', 'You have things.');
expect(missing).toContain('itemList');
expect(missing).toContain('cartUrl');
});
it('tolerates whitespace inside the braces', () => {
expect(missingPlaceholders('passwordReset', 'Go [here]({{ resetUrl }}).')).toEqual([]);
});
});
describe('renderTemplate', () => {
const resetValues = { resetUrl: 'https://shop.test/reset-password?token=abc' };
it('substitutes a placeholder into the rendered body', () => {
const { html } = renderTemplate('passwordReset', {}, resetValues);
expect(html).toContain('https://shop.test/reset-password?token=abc');
expect(html).not.toContain('{{resetUrl}}');
});
it('renders markdown as HTML', () => {
const { html } = renderTemplate(
'passwordReset',
{ body: 'Use **this** [link]({{resetUrl}}).' },
resetValues
);
expect(html).toContain('<strong>this</strong>');
expect(html).toContain('<a href="https://shop.test/reset-password?token=abc"');
});
// The reason markdown-it runs with html disabled. An admin editing copy must
// not be able to put script into a customer's inbox, and sanitising after the
// fact is a weaker guarantee than never emitting it.
it('escapes raw HTML in a stored body rather than passing it through', () => {
const { html } = renderTemplate(
'passwordReset',
{ body: '<script>alert(1)</script> [link]({{resetUrl}})' },
resetValues
);
expect(html).not.toContain('<script>');
expect(html).toContain('&lt;script&gt;');
});
it('falls back to the built-in body when nothing is stored', () => {
const stored = renderTemplate('passwordReset', {}, resetValues);
const explicit = renderTemplate(
'passwordReset',
{ body: TEMPLATES.passwordReset.defaultBody },
resetValues
);
expect(stored.html).toBe(explicit.html);
});
it('uses a stored subject over the default, and substitutes into it', () => {
const { subject } = renderTemplate(
'favoriteSold',
{ subject: '{{itemName}} is gone' },
{ itemName: 'Oak table', siteUrl: 'https://shop.test' }
);
expect(subject).toBe('Oak table is gone');
});
// Values are substituted into the markdown source, so a value that needs to
// become a list has to arrive as markdown. Emitting HTML here would be
// escaped and shown to the customer as literal tags.
it('renders a markdown list supplied as a placeholder value', () => {
const { html } = renderTemplate(
'cartReminder',
{},
{
greeting: 'Hi Thom,',
itemList: '- Oak table\n- Brass lamp',
cartUrl: 'https://shop.test/cart'
}
);
expect(html).toContain('<ul>');
expect(html).toContain('<li>Oak table</li>');
});
// The consent sentence explains why the email is lawful to send. It is
// appended by the server precisely so that editing the copy cannot remove it.
it.each(['favoriteSold', 'favoriteWithdrawn'] as TemplateKey[])(
'appends the unremovable consent footer to %s',
(key) => {
const { html } = renderTemplate(
key,
{ body: 'Short replacement copy about {{itemName}}.' },
{ itemName: 'Oak table', siteUrl: 'https://shop.test' }
);
expect(html).toContain('favorited');
expect(html).toContain('account page');
}
);
it('does not append that footer to templates it does not belong to', () => {
const { html } = renderTemplate('passwordReset', {}, resetValues);
expect(html).not.toContain('account page');
});
// An unsubstituted placeholder in the output means a caller forgot a value,
// and shipping "{{resetUrl}}" to a customer is worse than failing.
it('leaves no unsubstituted placeholders when every value is supplied', () => {
const { html, subject } = renderTemplate(
'cartReminder',
{},
{
greeting: 'Hi Thom,',
itemList: '- One thing',
cartUrl: 'https://shop.test/cart',
holdDuration: '24 hours'
}
);
expect(html).not.toMatch(/\{\{\s*\w+\s*\}\}/);
expect(subject).not.toMatch(/\{\{\s*\w+\s*\}\}/);
});
});
describe('SAMPLE_VALUES, which the admin preview renders with', () => {
// A missing sample renders the preview with a literal {{placeholder}} in it,
// which teaches the admin their copy is broken when it is not. Adding a
// placeholder to a template must mean adding a sample for it.
it.each(KEYS)('%s has a sample for every placeholder it accepts', (key) => {
const missing = TEMPLATES[key].available.filter((name) => !(name in SAMPLE_VALUES));
expect(missing).toEqual([]);
});
it.each(KEYS)('%s previews with no placeholder left unsubstituted', (key) => {
const { html, subject } = renderTemplate(key, {}, SAMPLE_VALUES);
expect(html).not.toMatch(/\{\{\s*\w+\s*\}\}/);
expect(subject).not.toMatch(/\{\{\s*\w+\s*\}\}/);
});
});
describe('formatDuration, which puts a configured lifetime into email copy', () => {
// The three templates that mention a duration all render it through this, so
// "one hour" in the reset email and "one hour" in the cart reminder are the
// same string produced the same way rather than two authors' phrasing.
it('spells a single hour rather than printing a numeral', () => {
expect(formatDuration(1)).toBe('one hour');
});
it('counts whole hours', () => {
expect(formatDuration(2)).toBe('2 hours');
expect(formatDuration(24)).toBe('24 hours');
});
// A fractional hour reads badly as "0.5 hours", and worse as "1.5 hours" in a
// sentence a customer is meant to act on.
it('drops to minutes for anything that is not a whole number of hours', () => {
expect(formatDuration(0.5)).toBe('30 minutes');
expect(formatDuration(0.25)).toBe('15 minutes');
expect(formatDuration(1.5)).toBe('90 minutes');
});
});
describe('the duration placeholders', () => {
it('offers expiresIn on the two templates that carry a link with a lifetime', () => {
expect(TEMPLATES.verification.available).toContain('expiresIn');
expect(TEMPLATES.passwordReset.available).toContain('expiresIn');
});
it('offers holdDuration on the cart reminder', () => {
expect(TEMPLATES.cartReminder.available).toContain('holdDuration');
});
// Required would reject every template an admin saved before this existed,
// and the whole point is that their copy keeps sending.
it.each([
['verification', 'expiresIn'],
['passwordReset', 'expiresIn'],
['cartReminder', 'holdDuration']
] as const)('does not make %s require %s', (key, name) => {
expect(TEMPLATES[key].required).not.toContain(name);
});
it('renders a body saved before the placeholder existed, unchanged', () => {
const { html } = renderTemplate(
'passwordReset',
{ subject: 'Reset it', body: 'Go [here]({{resetUrl}}). This link expires in one hour.' },
{ resetUrl: 'https://shop.test/r', expiresIn: 'two hours' }
);
expect(html).toContain('one hour');
expect(html).not.toMatch(/\{\{\s*\w+\s*\}\}/);
});
it('substitutes the duration when a body does use the placeholder', () => {
const { html } = renderTemplate(
'passwordReset',
{ subject: 'Reset it', body: 'Go [here]({{resetUrl}}). Expires in {{expiresIn}}.' },
{ resetUrl: 'https://shop.test/r', expiresIn: 'two hours' }
);
expect(html).toContain('two hours');
});
});
describe('greeting, built from the configured format', () => {
const FORMAT = 'Hi {{firstName}},';
const FALLBACK = 'Hi,';
it('substitutes the first name into the format', () => {
expect(greeting('Ada', FORMAT, FALLBACK)).toBe('Hi Ada,');
});
it('honours a format an admin has rewritten', () => {
expect(greeting('Ada', 'Dear {{firstName}} {{lastName}}:', FALLBACK, 'Lovelace'))
.toBe('Dear Ada Lovelace:');
});
it('tolerates whitespace inside the braces, as the renderer does', () => {
expect(greeting('Ada', 'Hi {{ firstName }},', FALLBACK)).toBe('Hi Ada,');
});
// The case #106 was about. Customers who registered while first names were
// optional genuinely have none, and substituting an empty string into the
// format would send them "Hi ,".
it.each([null, undefined, '', ' '])(
'uses the fallback whole rather than a format with a hole in it (%p)',
(name) => {
expect(greeting(name, FORMAT, FALLBACK)).toBe('Hi,');
}
);
it('does not leave a lastName placeholder behind when there is no last name', () => {
expect(greeting('Ada', 'Hi {{firstName}} {{lastName}},', FALLBACK, null))
.not.toMatch(/\{\{/);
});
});
describe('every template can address the customer', () => {
// Not KEYS: this is an invariant of the customer-facing templates only.
// intakeDraft and uploadLink notify the shop and a contributor respectively,
// not a customer with a name on file, so they are deliberately not held to
// it — a hardcoded list is correct here rather than a staleness risk,
// because the set of templates this claim applies to does not grow just
// because TEMPLATES does.
const CUSTOMER_FACING_KEYS: TemplateKey[] = [
'verification',
'passwordReset',
'favoriteSold',
'favoriteWithdrawn',
'cartReminder',
'emailChanged',
'emailChangedByAdmin'
];
it.each(CUSTOMER_FACING_KEYS)('%s offers greeting, firstName and lastName', (key) => {
expect(TEMPLATES[key].available).toEqual(
expect.arrayContaining(['greeting', 'firstName', 'lastName'])
);
});
});
describe('the intake notification template', () => {
// Without the review link the email is a notification you cannot act on.
it('requires the review url', () => {
expect(missingPlaceholders('intakeDraft', 'An item arrived.')).toContain('reviewUrl');
});
it('accepts a body carrying the review url', () => {
expect(missingPlaceholders('intakeDraft', 'Review it: {{reviewUrl}}')).toEqual([]);
});
// The signed links are deliberately optional. They are absent whenever
// INTAKE_ACTION_SECRET is unset, and a template demanding them would leave an
// unconfigured environment unable to send this at all.
it('does not require the signed action links', () => {
const missing = missingPlaceholders('intakeDraft', '{{reviewUrl}}');
expect(missing).not.toContain('discardUrl');
expect(missing).not.toContain('regenerateUrl');
});
it('offers the drafted copy to the template author', () => {
for (const name of ['itemName', 'draftName', 'draftDescription', 'price', 'submitterNote']) {
expect(TEMPLATES.intakeDraft.available).toContain(name);
}
});
// The email must never be able to publish. That is what bounds the risk taken
// by pricing items on arrival, and it is a property of the copy as much as of
// the routes — a publish link here would be one nobody reviewed.
it('offers no way to publish', () => {
expect(TEMPLATES.intakeDraft.available).not.toContain('publishUrl');
expect(TEMPLATES.intakeDraft.defaultBody).not.toMatch(/publishUrl/);
});
});