fix(api): treat blank env vars as unset; verify the whole flow in Docker Compose

Found by running the delivery flow against the actual built container rather
than a local dev process.

DEPLOY-02 (Critical) — ADMIN LOGIN RETURNED 500 FOR EVERY USER IN DOCKER.
docker-compose.yml passes each optional variable as ${X:-}, so an unconfigured
one arrives as "" rather than undefined. The code read them with
`process.env.X ?? 'default'`, which only falls back on undefined, so the empty
string reached jsonwebtoken and it threw

  "expiresIn" should be a number of seconds or string representing a timespan

on every sign. Invisible to every existing suite, all of which run against a
local process where the variable is genuinely absent.

Added src/common/env.ts (envOr / envOrNull), which treat blank as unset, and
applied it to every auth, port, upload and SMS read. Three were not cosmetic:
a blank UPLOAD_DIR would have served static files from the FILESYSTEM ROOT, a
blank PORT would have bound a random ephemeral port (Number('') === 0), and a
blank JWT_SECRET would have become the verifying key. Recorded as D25, with a
standing rule to use these readers instead of `??`.

Pinned by src/common/env.spec.ts (9 tests), which reproduces the original throw
against the real jsonwebtoken and asserts the reader can no longer produce it.

DEPLOY-03 (process, documented) — the API image is `COPY release/ ./`, so it
ships a bundle pre-built on the developer machine. `docker compose build` will
happily produce an image from a STALE release/app.js and report success; at the
start of this run that bundle was four days old and the rebuilt container was
missing every route added since. DELIVERY.md's deploy section now leads with
`pnpm build:release`.

New: scripts/test-compose-e2e.cjs — the whole delivery flow against the built
container, over HTTP only, as a black box: a sales expert and an ordinary
customer both sign in with the master code, both place an order, both orders
appear in the admin grid and open there, and the three token boundaries still
hold in the deployed image. 32/32.

Verified: compose e2e 32/32 · jest 54/54 · build 5/5 ·
against the CONTAINER: complete-profile 38/38, report 40/40, expert login 36/36 ·
locally: apps smoke 43/43, admin smoke 43/43, B2B scan 34 ok/0 broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-09-23 02:27:30 +03:30
co-authored by Claude Opus 5
parent 3f549f1749
commit 24b7a4c395
44 changed files with 11748 additions and 1828 deletions
+104
View File
@@ -0,0 +1,104 @@
import { envOr, envOrNull } from './env';
/**
* DEPLOY-02 — a blank environment variable must be treated as unset.
*
* `docker-compose.yml` passes every optional variable as `${X:-}`, so an
* unconfigured one arrives as `""`, not `undefined`. The codebase originally
* read these with `process.env.X ?? 'default'`, which only falls back on
* `undefined` — so the empty string won, and
*
* new JwtService().sign(payload, { expiresIn: '' })
*
* threw `"expiresIn" should be a number of seconds or string representing a
* timespan` on EVERY login. In Docker only, which is why it survived every
* suite that ran against a local dev process. These tests pin the behaviour.
*/
describe('envOr / envOrNull (DEPLOY-02)', () => {
const saved = { ...process.env };
const KEY = 'GOLDBANK_ENV_SPEC_KEY';
beforeEach(() => {
delete process.env[KEY];
});
afterAll(() => {
process.env = { ...saved };
});
describe('envOr', () => {
it('falls back when the variable is absent', () => {
expect(envOr(KEY, '12h')).toBe('12h');
});
it('falls back when the variable is the EMPTY STRING — the compose case', () => {
process.env[KEY] = '';
expect(envOr(KEY, '12h')).toBe('12h');
});
it('falls back when the variable is only whitespace', () => {
process.env[KEY] = ' ';
expect(envOr(KEY, '12h')).toBe('12h');
});
it('returns the value when one is set', () => {
process.env[KEY] = '30d';
expect(envOr(KEY, '12h')).toBe('30d');
});
it('trims a padded value rather than passing the padding through', () => {
process.env[KEY] = ' 30d ';
expect(envOr(KEY, '12h')).toBe('30d');
});
it('never returns an empty string, whatever is set', () => {
for (const v of ['', ' ', '\t', '\n', ' \t\n ']) {
process.env[KEY] = v;
expect(envOr(KEY, 'fallback')).not.toBe('');
}
});
});
describe('envOrNull', () => {
it('is null when absent, empty or blank', () => {
expect(envOrNull(KEY)).toBeNull();
process.env[KEY] = '';
expect(envOrNull(KEY)).toBeNull();
process.env[KEY] = ' ';
expect(envOrNull(KEY)).toBeNull();
});
it('is the trimmed value when set', () => {
process.env[KEY] = ' secret ';
expect(envOrNull(KEY)).toBe('secret');
});
});
/**
* The specific failure: an empty expiresIn must never reach jsonwebtoken.
* Asserted against the real library so the test fails if the reader regresses.
*/
describe('the original failure', () => {
it('jsonwebtoken rejects an empty expiresIn, and envOr never produces one', () => {
// Required rather than imported: jsonwebtoken is a transitive dependency
// of @nestjs/jwt and ships no types here. Typed loosely on purpose — this
// test is about the VALUE reaching it, not about its signature.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const jwt = require('jsonwebtoken') as {
sign: (payload: object, secret: string, opts: { expiresIn: string }) => string;
};
// The bug: this is exactly what `process.env.JWT_EXPIRES_IN ?? '12h'`
// produced under compose, and it threw on every login.
expect(() => jwt.sign({ sub: 'x' }, 'secret', { expiresIn: '' })).toThrow(
/expiresIn/,
);
// The fix: envOr turns that same blank into the default, which signs.
process.env[KEY] = '';
const token = jwt.sign({ sub: 'x' }, 'secret', { expiresIn: envOr(KEY, '12h') });
expect(typeof token).toBe('string');
expect(token.split('.')).toHaveLength(3);
});
});
});
+28
View File
@@ -0,0 +1,28 @@
/**
* Environment readers that treat a BLANK value as unset.
*
* `process.env.X ?? 'default'` only falls back when the variable is absent, so
* an empty string sails straight through. That matters here because
* `docker-compose.yml` passes every optional variable as `${X:-}`, which sets
* it to `""` when the operator has not configured it. jsonwebtoken then throws
*
* "expiresIn" should be a number of seconds or string representing a timespan
*
* and EVERY login 500s — in Docker only, which is why it survived every test
* that ran against a local dev process. Found by the pre-delivery compose run;
* recorded as DEPLOY-02.
*/
/** The variable's trimmed value, or `fallback` when it is unset, empty or blank. */
export function envOr(name: string, fallback: string): string {
const raw = process.env[name];
const trimmed = typeof raw === 'string' ? raw.trim() : '';
return trimmed === '' ? fallback : trimmed;
}
/** The variable's trimmed value, or null when it is unset, empty or blank. */
export function envOrNull(name: string): string | null {
const raw = process.env[name];
const trimmed = typeof raw === 'string' ? raw.trim() : '';
return trimmed === '' ? null : trimmed;
}