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>
240 lines
9.6 KiB
TypeScript
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/);
|
|
});
|
|
});
|
|
});
|