From 5a81caa7bbb1260e6ae14d565dccc24821c1c0cd Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sat, 16 Aug 2025 12:35:35 +0000 Subject: [PATCH] fix(qenv): Handle falsy environment values correctly, improve env source resolution, add tests and update test script --- changelog.md | 9 +++ package.json | 2 +- test/test.falsy.ts | 119 +++++++++++++++++++++++++++++++++++++++ ts/00_commitinfo_data.ts | 2 +- ts/qenv.classes.qenv.ts | 54 ++++++++++++------ 5 files changed, 168 insertions(+), 18 deletions(-) create mode 100644 test/test.falsy.ts diff --git a/changelog.md b/changelog.md index c6556b7..eacd755 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2025-08-16 - 6.1.3 - fix(qenv) +Handle falsy environment values correctly, improve env source resolution, add tests and update test script + +- Treat 0, false and empty string as valid environment values by checking for !== undefined when resolving env vars +- Refactor source resolution to iterate over environment, env file, Docker secrets and secret.json, returning the first defined value +- Ensure env.json and Docker secret JSON return strings for scalar values and base64-encode object values +- Add tests covering falsy values and lookup behavior (test/test.falsy.ts) +- Update package.json test script to run tstest with --verbose --testlog --timeout flags + ## 2025-08-14 - 6.1.2 - fix(readme) Correct DATABASE_CONFIG example formatting in README and add local settings configuration file diff --git a/package.json b/package.json index 7fc0677..fff8e82 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "typings": "dist_ts/index.d.ts", "type": "module", "scripts": { - "test": "(tstest test/)", + "test": "(tstest test/ --verbose --testlog --timeout 20)", "build": "(tsbuild --web --allowimplicitany)", "buildDocs": "tsdoc" }, diff --git a/test/test.falsy.ts b/test/test.falsy.ts new file mode 100644 index 0000000..6769fdd --- /dev/null +++ b/test/test.falsy.ts @@ -0,0 +1,119 @@ +import * as path from 'path'; +import { tap, expect } from '@git.zone/tstest/tapbundle'; +import * as qenv from '../ts/index.js'; + +const testDir = path.dirname(new URL(import.meta.url).pathname); + +// Test falsy values handling +tap.test('should handle falsy values correctly', async () => { + // Set up environment variables with falsy values + process.env['FALSY_FALSE'] = 'false'; + process.env['FALSY_ZERO'] = '0'; + process.env['FALSY_EMPTY'] = ''; + + const testQenv = new qenv.Qenv(testDir, testDir, false); + + // Test that falsy values are returned, not undefined + expect(await testQenv.getEnvVarOnDemand('FALSY_FALSE')).toEqual('false'); + expect(await testQenv.getEnvVarOnDemand('FALSY_ZERO')).toEqual('0'); + expect(await testQenv.getEnvVarOnDemand('FALSY_EMPTY')).toEqual(''); + + // Test sync versions + expect(testQenv.getEnvVarOnDemandSync('FALSY_FALSE')).toEqual('false'); + expect(testQenv.getEnvVarOnDemandSync('FALSY_ZERO')).toEqual('0'); + expect(testQenv.getEnvVarOnDemandSync('FALSY_EMPTY')).toEqual(''); + + // Test that undefined is still returned for non-existent variables + expect(await testQenv.getEnvVarOnDemand('NON_EXISTENT')).toBeUndefined(); + expect(testQenv.getEnvVarOnDemandSync('NON_EXISTENT')).toBeUndefined(); + + // Clean up + delete process.env['FALSY_FALSE']; + delete process.env['FALSY_ZERO']; + delete process.env['FALSY_EMPTY']; +}); + +tap.test('should handle falsy values in env.json file', async () => { + // Create a test env.json with falsy values + const testAssetsDir = path.join(testDir, 'assets-falsy'); + const fs = await import('fs'); + + // Create directory if it doesn't exist + if (!fs.existsSync(testAssetsDir)) { + fs.mkdirSync(testAssetsDir, { recursive: true }); + } + + // Create env.json with falsy values + const envJsonContent = { + JSON_FALSE: false, + JSON_ZERO: 0, + JSON_EMPTY: '' + }; + + fs.writeFileSync( + path.join(testAssetsDir, 'env.json'), + JSON.stringify(envJsonContent, null, 2) + ); + + const testQenv = new qenv.Qenv(testAssetsDir, testAssetsDir, false); + + // Test that falsy values from JSON are returned correctly + expect(await testQenv.getEnvVarOnDemand('JSON_FALSE')).toEqual('false'); + expect(await testQenv.getEnvVarOnDemand('JSON_ZERO')).toEqual('0'); + expect(await testQenv.getEnvVarOnDemand('JSON_EMPTY')).toEqual(''); + + // Clean up + fs.rmSync(testAssetsDir, { recursive: true, force: true }); +}); + +tap.test('should throw error for undefined in strict mode', async () => { + const testQenv = new qenv.Qenv(testDir, testDir, false); + + // Set a falsy value + process.env['FALSY_VALUE'] = '0'; + + // Should NOT throw for falsy value + let result; + try { + result = await testQenv.getEnvVarOnDemandStrict('FALSY_VALUE'); + } catch (error) { + // Should not reach here + expect(true).toBeFalse(); + } + expect(result).toEqual('0'); + + // Should throw for non-existent variable + let threwError = false; + try { + await testQenv.getEnvVarOnDemandStrict('NON_EXISTENT_VAR'); + } catch (error) { + threwError = true; + expect(error.message).toContain('is not set'); + } + expect(threwError).toBeTrue(); + + // Clean up + delete process.env['FALSY_VALUE']; +}); + +tap.test('should handle array of env vars with falsy values', async () => { + const testQenv = new qenv.Qenv(testDir, testDir, false); + + // Set up test environment + process.env['FIRST_VAR'] = '0'; + process.env['SECOND_VAR'] = 'false'; + + // Test that it returns the first defined value, even if falsy + const result = await testQenv.getEnvVarOnDemand(['NON_EXISTENT', 'FIRST_VAR', 'SECOND_VAR']); + expect(result).toEqual('0'); + + // Test sync version + const resultSync = testQenv.getEnvVarOnDemandSync(['NON_EXISTENT', 'FIRST_VAR', 'SECOND_VAR']); + expect(resultSync).toEqual('0'); + + // Clean up + delete process.env['FIRST_VAR']; + delete process.env['SECOND_VAR']; +}); + +export default tap.start(); \ No newline at end of file diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index db5ab6b..f4f1b30 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/qenv', - version: '6.1.2', + version: '6.1.3', description: 'A module for easily handling environment variables in Node.js projects with support for .yml and .json configuration.' } diff --git a/ts/qenv.classes.qenv.ts b/ts/qenv.classes.qenv.ts index 37755e9..778082f 100644 --- a/ts/qenv.classes.qenv.ts +++ b/ts/qenv.classes.qenv.ts @@ -71,7 +71,7 @@ export class Qenv { private loadAvailableEnvVars() { for (const envVar of this.requiredEnvVars) { const value = this.getEnvVarOnDemand(envVar); - if (value) { + if (value !== undefined) { this.availableEnvVars.push(envVar); this.keyValueObject[envVar] = value; } @@ -101,7 +101,7 @@ export class Qenv { if (Array.isArray(envVarNameOrNames)) { for (const envVarName of envVarNameOrNames) { const value = await this.tryGetEnvVar(envVarName); - if (value) { + if (value !== undefined) { return value; } } @@ -120,7 +120,7 @@ export class Qenv { envVarNameOrNames: TEnvVarRef | TEnvVarRef[] ): Promise { const value = await this.getEnvVarOnDemand(envVarNameOrNames); - if (!value) { + if (value === undefined) { throw new Error(`Env var ${envVarNameOrNames} is not set!`); } return value; @@ -132,7 +132,7 @@ export class Qenv { if (Array.isArray(envVarNameOrNames)) { for (const envVarName of envVarNameOrNames) { const value = this.tryGetEnvVarSync(envVarName); - if (value) { + if (value !== undefined) { return value; } } @@ -156,21 +156,37 @@ export class Qenv { return await envVarRefArg(); } - return ( - this.getFromEnvironmentVariable(envVarRefArg) || - this.getFromEnvYamlOrJsonFile(envVarRefArg) || - this.getFromDockerSecret(envVarRefArg) || + const sources = [ + this.getFromEnvironmentVariable(envVarRefArg), + this.getFromEnvYamlOrJsonFile(envVarRefArg), + this.getFromDockerSecret(envVarRefArg), this.getFromDockerSecretJson(envVarRefArg) - ); + ]; + + for (const value of sources) { + if (value !== undefined) { + return value; + } + } + + return undefined; } private tryGetEnvVarSync(envVarName: string): string | undefined { - return ( - this.getFromEnvironmentVariable(envVarName) || - this.getFromEnvYamlOrJsonFile(envVarName) || - this.getFromDockerSecret(envVarName) || + const sources = [ + this.getFromEnvironmentVariable(envVarName), + this.getFromEnvYamlOrJsonFile(envVarName), + this.getFromDockerSecret(envVarName), this.getFromDockerSecretJson(envVarName) - ); + ]; + + for (const value of sources) { + if (value !== undefined) { + return value; + } + } + + return undefined; } private getFromEnvironmentVariable(envVarName: string): string | undefined { @@ -184,10 +200,13 @@ export class Qenv { try { const envJson = plugins.smartfile.fs.toObjectSync(this.envFilePathAbsolute); const value = envJson[envVarName]; + if (value === undefined) { + return undefined; + } if (typeof value === 'object') { return 'base64Object:' + this.encodeBase64(value); } - return value; + return String(value); } catch (error) { return undefined; } @@ -208,10 +227,13 @@ export class Qenv { if (secret.includes('secret.json')) { const secretObject = plugins.smartfile.fs.toObjectSync(`/run/secrets/${secret}`); const value = secretObject[envVarName]; + if (value === undefined) { + continue; + } if (typeof value === 'object') { return 'base64Object:' + this.encodeBase64(value); } - return value; + return String(value); } } }