Nothing a customer can see. The schema change and the configuration land on their own so the widest-reaching edit in the project can be reviewed for what it is rather than buried inside a feature. The password hash becomes nullable. That is one line and it is not the work; the work is that every read of the column is now a question rather than a fact. Three places compared against it with bcrypt, and all three now ask first through one shared function. That function exists because the alternative is worse than a wrong answer. bcrypt.compare throws on a null hash rather than returning false, so any call site that forgot the check would answer a sign-in attempt with a 500 instead of a refusal. On the login route that is also an oracle, because it would happen for exactly the accounts that have no password. One function rather than a null check repeated three times means the question is asked identically everywhere and a fourth site cannot forget to ask it. Nothing writes a null yet. The first accounts without a password arrive with the sign-up path, which is why this is landed ahead of them. The identities table is a table rather than columns on customers, because one customer may eventually hold more than one. Columns would make a second provider a migration and a third an embarrassment. Its important column is the provider subject, and the comment on it is the whole security posture of the feature in one place: never the email. An email is a display value its owner can change and a provider may reassign; a subject is opaque and stable for the life of the account. Matching on the email would strand a customer who changed theirs and, far worse, hand their account to whoever inherited the old address. Unique across the provider and subject together, not the subject alone. The down migration drops the table and deliberately does not restore the NOT NULL. Re-adding it fails outright once a passwordless customer exists, and a down migration that destroys accounts to satisfy a constraint is far worse than a column that is merely more permissive than it needs to be. The redirect URI is derived from PUBLIC_URL, the same single source the WebAuthn Relying Party ID uses and for the same reason: Google compares it as an exact string and answers a mismatch with a message that says nothing about which half is wrong. Deriving it means the value is correct by construction anywhere the email links already are. The tests are mostly about what must not end up in it, since a trailing slash on PUBLIC_URL is an easy way to produce a URI that is one character from the registered one. The config also reports whether it is enabled at all, so a developer without credentials gets a storefront that works and simply does not offer the button, rather than one that offers it and fails. Absent rather than disabled, the same choice made for a browser without WebAuthn. Environment validation refuses to boot on one credential without the other, matching how the SMTP pair is handled. Half-configured is the case worth catching because the failure otherwise arrives at the moment a customer presses the button. The QA compose file sets both to empty, and the comment there says why at length rather than leaving it to look like an oversight. Google refuses a redirect URI whose host is not under a domain whose ownership has been proved by DNS, and nobody can prove ownership of anything under bermudalamb.synology.me because Synology owns the registrable domain above it. That is the same wall #285 hit with Cloudflare. So QA cannot run this at all until #313 moves it to a subdomain of the real domain, at which point it is two stack variables and one console entry, with no code change either way. Also corrects the record in #332, which lists account deletion as confirming with a password. It does not; the route takes none and the confirmation is a modal in the account page. Deletion needed no change here. Verified: backend tsc clean for src and tests, 550 unit tests pass including new coverage of the config derivation, the null-hash comparison and the environment rules; lint clean apart from warnings that predate this branch. The integration suite needs a database this machine has no Docker for. Closes #340 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
295 lines
12 KiB
TypeScript
295 lines
12 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/);
|
|
});
|
|
});
|
|
// #224. MINIMAL has no INTAKE_ACTION_SECRET, so it is already the absent case.
|
|
describe('the intake action secret', () => {
|
|
it('is not required', () => {
|
|
expect(validateEnv(MINIMAL).errors).toEqual([]);
|
|
});
|
|
|
|
it('warns when it is absent', () => {
|
|
expect(validateEnv(MINIMAL).warnings.join(' ')).toMatch(/INTAKE_ACTION_SECRET/);
|
|
});
|
|
|
|
it('says nothing when it is set', () => {
|
|
const { warnings } = validateEnv(withEnv({ INTAKE_ACTION_SECRET: 'a-secret' }));
|
|
expect(warnings.join(' ')).not.toMatch(/INTAKE_ACTION_SECRET/);
|
|
});
|
|
});
|
|
|
|
// #340. All or nothing, and half-configured is the case worth catching: the
|
|
// failure would otherwise arrive when a customer presses the button.
|
|
describe('Google sign-in credentials', () => {
|
|
const BOTH = { GOOGLE_CLIENT_ID: 'id', GOOGLE_CLIENT_SECRET: 'shh', PUBLIC_URL: 'https://x.test' };
|
|
|
|
it('are not required', () => {
|
|
expect(validateEnv(MINIMAL).errors).toEqual([]);
|
|
});
|
|
|
|
it('warn when absent, so a missing button has a stated reason', () => {
|
|
expect(validateEnv(MINIMAL).warnings.join(' ')).toMatch(/Google sign-in is not configured/);
|
|
});
|
|
|
|
it('refuse to start with only the client id', () => {
|
|
const { errors } = validateEnv(withEnv({ GOOGLE_CLIENT_ID: 'id' }));
|
|
expect(errors.join(' ')).toMatch(/GOOGLE_CLIENT_SECRET is required/);
|
|
});
|
|
|
|
it('refuse to start with only the client secret', () => {
|
|
const { errors } = validateEnv(withEnv({ GOOGLE_CLIENT_SECRET: 'shh' }));
|
|
expect(errors.join(' ')).toMatch(/GOOGLE_CLIENT_ID is required/);
|
|
});
|
|
|
|
it('are accepted when both are set alongside PUBLIC_URL', () => {
|
|
const { errors, warnings } = validateEnv(withEnv(BOTH));
|
|
expect(errors).toEqual([]);
|
|
expect(warnings.join(' ')).not.toMatch(/Google sign-in/);
|
|
});
|
|
|
|
it('warn when configured without PUBLIC_URL, because the callback falls back to localhost', () => {
|
|
// Correct locally and wrong everywhere else, which is precisely the shape
|
|
// that needs saying out loud rather than failing at Google later.
|
|
const { errors, warnings } = validateEnv(
|
|
withEnv({ GOOGLE_CLIENT_ID: 'id', GOOGLE_CLIENT_SECRET: 'shh' })
|
|
);
|
|
expect(errors).toEqual([]);
|
|
expect(warnings.join(' ')).toMatch(/redirect URI falls back to localhost/);
|
|
});
|
|
});
|
|
});
|