Files
redefined-designs/backend/tests/unit/envValidation.test.ts
T
bermudalambandClaude Opus 5 747ed2d00a build(intake): add the Anthropic SDK and warn when its key is absent (#223)
Both packages go in dependencies rather than devDependencies. The final Docker stage installs with --omit=dev, so the wrong section produces a container that fails on the first submission and nowhere else — which is how sharp went wrong in #226.

ANTHROPIC_API_KEY is a warning, not a requirement. Absent, the container still boots and a submission still arrives, keeps its photos and waits in the queue undrafted. The photos are often the only copy of an item no longer in the sender's hands, so losing a consignment to an expired key would be a worse outcome than an item arriving without its description written. Silence would be wrong too: an operator who believes drafting is on and finds every item undrafted has nothing to tell them why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 19:25:49 -05:00

240 lines
9.6 KiB
TypeScript

import { validateEnv } from '../../src/envValidation';
// The smallest environment that should boot: demo mode on, a database, and
// somewhere to put uploads. Everything else is optional or conditional.
const MINIMAL: NodeJS.ProcessEnv = {
DEMO_MODE: 'true',
PGHOST: 'localhost',
PGPORT: '5432',
PGUSER: 'someone',
PGPASSWORD: 'secret',
PGDATABASE: 'redefined',
UPLOADS_DIR: '/tmp/uploads'
};
const withEnv = (extra: NodeJS.ProcessEnv): NodeJS.ProcessEnv => ({ ...MINIMAL, ...extra });
// `undefined` removes a key rather than setting it to the string "undefined".
const without = (...names: string[]): NodeJS.ProcessEnv => {
const env = { ...MINIMAL };
for (const name of names) delete env[name];
return env;
};
describe('validateEnv', () => {
it('accepts the minimal environment local development already uses', () => {
expect(validateEnv(MINIMAL).errors).toEqual([]);
});
describe('variables that are always required', () => {
it.each(['PGHOST', 'PGPORT', 'PGUSER', 'PGPASSWORD', 'PGDATABASE', 'UPLOADS_DIR'])(
'refuses to boot without %s',
(name) => {
const { errors } = validateEnv(without(name));
expect(errors.some((e) => e.includes(name))).toBe(true);
}
);
// The fallback of '/app/uploads' is right inside the container and wrong
// everywhere else, which is why this one gets no reprieve.
it('names UPLOADS_DIR rather than silently accepting its fallback', () => {
const { errors } = validateEnv(without('UPLOADS_DIR'));
expect(errors.some((e) => e.includes('UPLOADS_DIR'))).toBe(true);
});
// Reporting one problem per boot makes fixing a fresh environment a
// sequence of restarts.
it('reports every problem at once rather than stopping at the first', () => {
const { errors } = validateEnv(without('PGHOST', 'PGUSER', 'UPLOADS_DIR'));
expect(errors).toHaveLength(3);
});
});
describe('DEMO_MODE', () => {
it('is required', () => {
const { errors } = validateEnv(without('DEMO_MODE'));
expect(errors.some((e) => e.includes('DEMO_MODE'))).toBe(true);
});
it.each(['true', 'false'])('accepts the exact value %s', (value) => {
const env = withEnv({
DEMO_MODE: value,
// Real payments need credentials; supplied so this case tests DEMO_MODE
// alone rather than tripping the PayPal rule.
PAYPAL_CLIENT_ID: 'id',
PAYPAL_CLIENT_SECRET: 'secret',
PAYPAL_WEBHOOK_ID: 'hook',
PAYPAL_ENV: 'sandbox'
});
expect(validateEnv(env).errors).toEqual([]);
});
// The whole point of this issue. Before, any value that was not exactly
// 'false' meant demo mode was on — so a typo silently stopped the shop
// charging anyone.
it.each(['False', 'FALSE', '0', 'no', 'flase', ''])(
'refuses %p rather than reading it as demo mode',
(value) => {
const { errors } = validateEnv(withEnv({ DEMO_MODE: value }));
expect(errors.some((e) => e.includes('DEMO_MODE'))).toBe(true);
}
);
it('quotes the value it was given, so the typo is visible in the message', () => {
const { errors } = validateEnv(withEnv({ DEMO_MODE: 'False' }));
expect(errors.find((e) => e.includes('DEMO_MODE'))).toContain("'False'");
});
});
describe('PayPal credentials', () => {
// QA runs with no PayPal on purpose, so this cannot be an unconditional
// requirement — it is tied to real payments being switched on.
it('are not required while demo mode is on', () => {
expect(validateEnv(withEnv({ DEMO_MODE: 'true' })).errors).toEqual([]);
});
it.each(['PAYPAL_CLIENT_ID', 'PAYPAL_CLIENT_SECRET', 'PAYPAL_WEBHOOK_ID', 'PAYPAL_ENV'])(
'are required when demo mode is off — missing %s',
(name) => {
const full = withEnv({
DEMO_MODE: 'false',
PAYPAL_CLIENT_ID: 'id',
PAYPAL_CLIENT_SECRET: 'secret',
PAYPAL_WEBHOOK_ID: 'hook',
PAYPAL_ENV: 'live'
});
delete full[name];
const { errors } = validateEnv(full);
expect(errors.some((e) => e.includes(name))).toBe(true);
}
);
});
describe('SMTP', () => {
it('is optional, and its absence is a warning rather than an error', () => {
const { errors, warnings } = validateEnv(MINIMAL);
expect(errors).toEqual([]);
expect(warnings.some((w) => w.includes('SMTP'))).toBe(true);
});
// Half-configured is worse than not configured: it looks set up and fails
// at send time.
it('refuses SMTP_USER without SMTP_PASSWORD', () => {
const { errors } = validateEnv(withEnv({ SMTP_USER: 'someone', PUBLIC_URL: 'https://x.test' }));
expect(errors.some((e) => e.includes('SMTP_PASSWORD'))).toBe(true);
});
it('refuses SMTP_PASSWORD without SMTP_USER', () => {
const { errors } = validateEnv(withEnv({ SMTP_PASSWORD: 'secret', PUBLIC_URL: 'https://x.test' }));
expect(errors.some((e) => e.includes('SMTP_USER'))).toBe(true);
});
it('accepts both together', () => {
const env = withEnv({
SMTP_USER: 'someone',
SMTP_PASSWORD: 'secret',
PUBLIC_URL: 'https://x.test',
MAIL_ALLOWLIST: 'someone@example.com'
});
expect(validateEnv(env).errors).toEqual([]);
});
});
describe('PUBLIC_URL', () => {
// It exists only to build links in email. A local environment that cannot
// send mail does not need it, and demanding it would break every existing
// local setup to prevent nothing.
it('is not required when no mail can be sent', () => {
expect(validateEnv(MINIMAL).errors).toEqual([]);
});
it('is required once SMTP is configured, because the links would read undefined', () => {
const env = withEnv({ SMTP_USER: 'someone', SMTP_PASSWORD: 'secret' });
const { errors } = validateEnv(env);
expect(errors.some((e) => e.includes('PUBLIC_URL'))).toBe(true);
});
});
describe('warnings that are not failures', () => {
// An environment that can send mail with no allowlist can reach real
// customers, which is what #87 exists to prevent.
it('warns when mail can be sent with no allowlist', () => {
const env = withEnv({ SMTP_USER: 'someone', SMTP_PASSWORD: 'secret', PUBLIC_URL: 'https://x.test' });
const { warnings } = validateEnv(env);
expect(warnings.some((w) => w.includes('MAIL_ALLOWLIST'))).toBe(true);
});
it('does not warn about the allowlist when there is no way to send mail', () => {
const { warnings } = validateEnv(MINIMAL);
expect(warnings.some((w) => w.includes('MAIL_ALLOWLIST'))).toBe(false);
});
it('warns when the admin gate is inactive', () => {
const { warnings } = validateEnv(MINIMAL);
expect(warnings.some((w) => w.includes('ADMIN_GATE_SECRET'))).toBe(true);
});
it('stays quiet about the admin gate once it is configured', () => {
const { warnings } = validateEnv(withEnv({ ADMIN_GATE_SECRET: 'a-secret' }));
expect(warnings.some((w) => w.includes('ADMIN_GATE_SECRET'))).toBe(false);
});
});
// A variable set to spaces is a configuration mistake, not a value.
it('treats a whitespace-only value as absent', () => {
const { errors } = validateEnv(withEnv({ UPLOADS_DIR: ' ' }));
expect(errors.some((e) => e.includes('UPLOADS_DIR'))).toBe(true);
});
});
// #103. Optional, like the admin gate: unset is a working configuration with
// one defence switched off, and set-but-wrong is worse than either.
describe('UPLOADS_BASE_URL', () => {
it('warns when it is unset, since the isolation is simply off', () => {
const { errors, warnings } = validateEnv(MINIMAL);
expect(errors).not.toContainEqual(expect.stringContaining('UPLOADS_BASE_URL'));
expect(warnings).toContainEqual(expect.stringContaining('UPLOADS_BASE_URL is not set'));
});
it('is satisfied by an absolute origin', () => {
const { errors, warnings } = validateEnv(withEnv({ UPLOADS_BASE_URL: 'https://uploads.example.com' }));
expect(errors).not.toContainEqual(expect.stringContaining('UPLOADS_BASE_URL'));
expect(warnings).not.toContainEqual(expect.stringContaining('UPLOADS_BASE_URL'));
});
// A hostname with no scheme joins onto a stored path as if it were relative,
// which breaks every image on the site rather than failing visibly. Refusing
// to start is the kinder outcome.
it('refuses a value with no scheme', () => {
const { errors } = validateEnv(withEnv({ UPLOADS_BASE_URL: 'uploads.example.com' }));
expect(errors).toContainEqual(expect.stringContaining('absolute origin'));
});
it('refuses a path rather than an origin', () => {
const { errors } = validateEnv(withEnv({ UPLOADS_BASE_URL: '/uploads' }));
expect(errors).toContainEqual(expect.stringContaining('absolute origin'));
});
// #223. MINIMAL deliberately has no ANTHROPIC_API_KEY, so it is already the
// absent case.
describe('the intake drafting key', () => {
// Absent is a working configuration, so this must never reach the errors
// list. A submission that arrives undrafted is a far better outcome than a
// container that will not boot.
it('is not required', () => {
expect(validateEnv(MINIMAL).errors).toEqual([]);
});
// But silence would be worse than a warning: an operator who thinks
// drafting is on and finds every item undrafted has no way to tell why.
it('warns when it is absent', () => {
expect(validateEnv(MINIMAL).warnings.join(' ')).toMatch(/ANTHROPIC_API_KEY/);
});
it('says nothing when it is set', () => {
const { warnings } = validateEnv(withEnv({ ANTHROPIC_API_KEY: 'sk-ant-test' }));
expect(warnings.join(' ')).not.toMatch(/ANTHROPIC_API_KEY/);
});
});
});