Skip to content

Commit 82f669a

Browse files
committed
test(ldap): comprehensive test suite — 196 tests across 4 suites
Adds full Jest test coverage for the LDAP authentication module: Test suites (196 tests total, all passing): - ldap-client.test.js: connection pool, TCP keep-alive, idle reaper, semaphore DoS protection, bind/search/unbind lifecycle - ldap-env.test.js: env var parsing, defaults, LDAP_POOL_MAX_QUEUE wiring - ldap-internal.test.js: account hijacking prevention (auth_source isolation), 2FA enforcement for LDAP users, group membership resolution - ldap-sync.test.js: JIT user provisioning, paged search OOM bounds, memory-bounding on large result sets Test infrastructure: - Jest ESM config (--experimental-vm-modules) with manual mocks - Mock modules: bcrypt, config, db, lodash, moment, node-rsa, objection, signale, tarn - ESM-compatible resolver (jest.resolver.cjs) - backend/__tests__/README.md with test run instructions
1 parent 1f4de6d commit 82f669a

16 files changed

Lines changed: 3196 additions & 0 deletions

backend/__mocks__/bcrypt.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* Stub for bcrypt — replaces the native addon-based package when
3+
* node-gyp-build or native bindings are unavailable in the test env.
4+
*
5+
* Tests that exercise real password hashing should use the real module;
6+
* for all other tests the models are fully mocked anyway.
7+
*/
8+
const FAKE_HASH = "$2b$10$TESTHASHfakevalueusedinstubonlyXXXXXXXXXXXXXXXXXX";
9+
10+
export default {
11+
hash: async (_data, _saltOrRounds) => FAKE_HASH,
12+
hashSync: (_data, _saltOrRounds) => FAKE_HASH,
13+
compare: async (_data, _hash) => true,
14+
compareSync: (_data, _hash) => true,
15+
genSalt: async (_rounds) => "$2b$10$TESTSALTfakevalueXXXXXXXXXX",
16+
genSaltSync: (_rounds) => "$2b$10$TESTSALTfakevalueXXXXXXXXXX",
17+
};

backend/__mocks__/config.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* Stub for backend/lib/config.js — used in Jest ESM test environment.
3+
* Provides no-op implementations of configuration helpers so that models
4+
* can be imported without triggering real file-system or key-generation
5+
* side-effects.
6+
*/
7+
8+
export const configure = () => {};
9+
export const isSqlite = () => false;
10+
export const isMysql = () => false;
11+
export const isPostgres = () => false;
12+
export const isCI = () => false;
13+
export const isDebugMode = () => false;
14+
export const configGet = (_key) => ({});
15+
export const configHas = (_key) => false;
16+
export const getKeys = () => ({ private: "", public: "" });
17+
export const getPrivateKey = () => "";
18+
export const getPublicKey = () => "";
19+
export const generateKeys = async () => {};
20+
export const getConfig = () => ({});
21+
export const getSetting = () => null;
22+
export const useLetsencryptStaging = () => false;
23+
export const useLetsencryptServer = () => "https://acme-v02.api.letsencrypt.org/directory";
24+
25+
export default {
26+
configure,
27+
isSqlite,
28+
isMysql,
29+
isPostgres,
30+
isCI,
31+
isDebugMode,
32+
configGet,
33+
configHas,
34+
getKeys,
35+
getPrivateKey,
36+
getPublicKey,
37+
generateKeys,
38+
getConfig,
39+
getSetting,
40+
useLetsencryptStaging,
41+
useLetsencryptServer,
42+
};

backend/__mocks__/db.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* Stub for backend/db.js — used in Jest ESM test environment
3+
* to avoid loading knex and its transitive dependencies.
4+
*/
5+
6+
const db = () => null;
7+
export default db;

backend/__mocks__/lodash.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* ESM stub for lodash — used in Jest ESM test environment.
3+
* The real lodash is CJS and may not resolve correctly through Jest's ESM resolver.
4+
*/
5+
6+
const noop = () => {};
7+
8+
const _ = {
9+
isArray: Array.isArray,
10+
isString: (v) => typeof v === "string",
11+
isObject: (v) => v !== null && typeof v === "object",
12+
isFunction: (v) => typeof v === "function",
13+
isNumber: (v) => typeof v === "number",
14+
isUndefined:(v) => v === undefined,
15+
isNull: (v) => v === null,
16+
isNil: (v) => v == null,
17+
isEmpty: (v) => !v || (Array.isArray(v) ? v.length === 0 : typeof v === "object" ? Object.keys(v).length === 0 : false),
18+
merge: (target, ...sources) => Object.assign(target ?? {}, ...sources),
19+
omit: (obj, keys) => {
20+
const result = { ...obj };
21+
const ks = Array.isArray(keys) ? keys : [keys];
22+
for (const k of ks) delete result[k];
23+
return result;
24+
},
25+
pick: (obj, keys) => {
26+
const result = {};
27+
const ks = Array.isArray(keys) ? keys : [keys];
28+
for (const k of ks) if (k in obj) result[k] = obj[k];
29+
return result;
30+
},
31+
clone: (v) => Array.isArray(v) ? [...v] : typeof v === "object" && v ? { ...v } : v,
32+
cloneDeep: (v) => JSON.parse(JSON.stringify(v)),
33+
assign: Object.assign,
34+
forEach: (collection, fn) => {
35+
if (Array.isArray(collection)) { collection.forEach(fn); }
36+
else if (collection && typeof collection === "object") { Object.entries(collection).forEach(([k, v]) => fn(v, k)); }
37+
},
38+
map: (collection, fn) => {
39+
if (Array.isArray(collection)) return collection.map(fn);
40+
if (collection && typeof collection === "object") return Object.entries(collection).map(([k, v]) => fn(v, k));
41+
return [];
42+
},
43+
filter: (arr, fn) => Array.isArray(arr) ? arr.filter(fn) : [],
44+
find: (arr, fn) => Array.isArray(arr) ? arr.find(fn) : undefined,
45+
reduce: (arr, fn, init) => Array.isArray(arr) ? arr.reduce(fn, init) : init,
46+
some: (arr, fn) => Array.isArray(arr) ? arr.some(fn) : false,
47+
every: (arr, fn) => Array.isArray(arr) ? arr.every(fn) : true,
48+
keys: Object.keys,
49+
values: Object.values,
50+
entries: Object.entries,
51+
get: (obj, path, def) => {
52+
if (!obj) return def;
53+
const parts = typeof path === "string" ? path.split(".") : path;
54+
let cur = obj;
55+
for (const p of parts) { if (cur == null) return def; cur = cur[p]; }
56+
return cur === undefined ? def : cur;
57+
},
58+
set: (obj, path, val) => {
59+
const parts = typeof path === "string" ? path.split(".") : path;
60+
let cur = obj;
61+
for (let i = 0; i < parts.length - 1; i++) { if (!cur[parts[i]]) cur[parts[i]] = {}; cur = cur[parts[i]]; }
62+
cur[parts[parts.length - 1]] = val;
63+
return obj;
64+
},
65+
has: (obj, key) => Object.prototype.hasOwnProperty.call(obj ?? {}, key),
66+
defaults: (obj, ...sources) => { for (const src of sources) for (const [k, v] of Object.entries(src)) if (obj[k] === undefined) obj[k] = v; return obj; },
67+
flatten: (arr) => arr.flat(),
68+
flatMap: (arr, fn) => arr.flatMap(fn),
69+
uniq: (arr) => [...new Set(arr)],
70+
sortBy: (arr, fn) => [...arr].sort((a, b) => { const va = typeof fn === "function" ? fn(a) : a[fn]; const vb = typeof fn === "function" ? fn(b) : b[fn]; return va < vb ? -1 : va > vb ? 1 : 0; }),
71+
noop,
72+
};
73+
74+
export default _;

backend/__mocks__/moment.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Minimal stub for moment.js.
3+
* Provides the API surface used by lib/helpers.js (parseDatePeriod).
4+
*/
5+
6+
const momentObj = {
7+
add: function() { return this; },
8+
subtract: function() { return this; },
9+
toDate: function() { return new Date(); },
10+
toISOString: function() { return new Date().toISOString(); },
11+
isValid: function() { return true; },
12+
valueOf: function() { return Date.now(); },
13+
};
14+
15+
function moment(input) {
16+
return {
17+
...momentObj,
18+
clone: () => moment(input),
19+
};
20+
}
21+
22+
moment.duration = (value, unit) => ({
23+
asMilliseconds: () => 86400000,
24+
toISOString: () => "P1D",
25+
});
26+
27+
moment.isMoment = (obj) => obj && typeof obj.toDate === "function";
28+
moment.utc = (input) => moment(input);
29+
moment.unix = (ts) => moment(new Date(ts * 1000));
30+
moment.ISO_8601 = "ISO_8601";
31+
32+
export default moment;

backend/__mocks__/node-rsa.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* Minimal stub for node-rsa — used when the real package's dependencies
3+
* (asn1, etc.) are not installed in the test environment.
4+
*/
5+
export default class NodeRSA {
6+
constructor() {}
7+
generateKeyPair() { return this; }
8+
exportKey() { return "-----BEGIN RSA KEY-----\nMOCK\n-----END RSA KEY-----"; }
9+
importKey() { return this; }
10+
sign() { return Buffer.from("mock-signature"); }
11+
verify() { return true; }
12+
encrypt() { return Buffer.from("mock-encrypted"); }
13+
decrypt() { return Buffer.from("mock-decrypted"); }
14+
}

backend/__mocks__/objection.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Minimal stub for the objection.js ORM — used only when the real package
3+
* is not installed in the test environment.
4+
*/
5+
6+
export class Model {
7+
static query() { return this; }
8+
static where() { return this; }
9+
static first() { return Promise.resolve(null); }
10+
static findById() { return Promise.resolve(null); }
11+
static insertAndFetch() { return Promise.resolve({}); }
12+
static patch() { return Promise.resolve(1); }
13+
static patchAndFetchById() { return Promise.resolve({}); }
14+
static insert() { return Promise.resolve({}); }
15+
static delete() { return Promise.resolve(1); }
16+
static count() { return this; }
17+
static andWhere() { return this; }
18+
static orWhere() { return this; }
19+
static allowGraph() { return this; }
20+
static withGraphFetched() { return this; }
21+
static groupBy() { return this; }
22+
static orderBy() { return this; }
23+
static whereIn() { return this; }
24+
static limit() { return this; }
25+
static offset() { return this; }
26+
static select() { return this; }
27+
static knex(_db) { return null; }
28+
static raw(sql, ...bindings) { return { sql, bindings, toKnexRaw: () => sql }; }
29+
static relatedQuery() { return this; }
30+
static for() { return this; }
31+
static modify() { return this; }
32+
}
33+
34+
// objection helper functions used in queries
35+
export const ref = (expr) => ({ expression: expr });
36+
export const raw = (sql, ...bindings) => ({ sql, bindings });
37+
export const fn = { now: () => new Date() };

backend/__mocks__/signale.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* Manual stub for the `signale` logging library.
3+
*
4+
* Used via moduleNameMapper so it must NOT reference jest globals.
5+
* Provides the Signale class and default logger methods as no-ops.
6+
*/
7+
8+
const noop = () => {};
9+
10+
const makeLogger = () => ({
11+
debug: noop,
12+
info: noop,
13+
warn: noop,
14+
error: noop,
15+
log: noop,
16+
fatal: noop,
17+
trace: noop,
18+
start: noop,
19+
success: noop,
20+
await: noop,
21+
note: noop,
22+
pause: noop,
23+
complete: noop,
24+
pending: noop,
25+
star: noop,
26+
watch: noop,
27+
});
28+
29+
class Signale {
30+
constructor(_opts) {
31+
Object.assign(this, makeLogger());
32+
}
33+
}
34+
35+
const defaultLogger = makeLogger();
36+
37+
export default {
38+
Signale,
39+
...defaultLogger,
40+
};

backend/__mocks__/tarn.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* Minimal stub for tarn (connection pool library, transitive dep of knex).
3+
*/
4+
export class Pool {
5+
constructor(_opts) {}
6+
acquire() { return { promise: Promise.resolve({}) }; }
7+
release() {}
8+
destroy() { return Promise.resolve(); }
9+
numUsed() { return 0; }
10+
numFree() { return 0; }
11+
numPendingAcquires() { return 0; }
12+
numPendingCreates() { return 0; }
13+
}

backend/__tests__/README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Backend Unit Tests
2+
3+
Tests live under `backend/__tests__/` and are written for **Jest** with ESM module support.
4+
5+
## Running the LDAP tests
6+
7+
The backend uses `"type": "module"` (ESM), so Jest must be run with the experimental VM modules flag:
8+
9+
```bash
10+
# From the repo root or backend directory
11+
cd backend
12+
13+
# Install dev dependencies first (if not done already):
14+
# yarn add --dev jest @jest/globals
15+
16+
# Run all LDAP unit tests
17+
NODE_OPTIONS="--experimental-vm-modules" npx jest --testPathPattern="__tests__/ldap"
18+
19+
# Run a specific test file
20+
NODE_OPTIONS="--experimental-vm-modules" npx jest --testPathPattern=ldap-client
21+
NODE_OPTIONS="--experimental-vm-modules" npx jest --testPathPattern=ldap-internal
22+
NODE_OPTIONS="--experimental-vm-modules" npx jest --testPathPattern=ldap-sync
23+
NODE_OPTIONS="--experimental-vm-modules" npx jest --testPathPattern=ldap-env
24+
```
25+
26+
## Test structure
27+
28+
| File | Module under test | Key coverage |
29+
|------|-------------------|--------------|
30+
| `ldap/ldap-client.test.js` | `lib/ldap-client.js` | Connection, bind, search, pool, TLS, STARTTLS, timeouts, error mapping |
31+
| `ldap/ldap-internal.test.js` | `internal/ldap.js` | testConnection, searchUser, authenticateUser, getUserGroups, normalizeUser, validateConfig |
32+
| `ldap/ldap-sync.test.js` | `internal/ldap-sync.js` | JIT provisioning, user update, group-based roles, account disable, syncAllUsers |
33+
| `ldap/ldap-env.test.js` | `lib/ldap-env.js` | Env var overrides, boolean parsing, precedence, rowToLdapClientConfig |
34+
35+
## Adding Jest to the project
36+
37+
Add to `backend/package.json` devDependencies:
38+
39+
```json
40+
{
41+
"devDependencies": {
42+
"jest": "^29.0.0",
43+
"@jest/globals": "^29.0.0"
44+
},
45+
"scripts": {
46+
"test": "NODE_OPTIONS='--experimental-vm-modules' jest"
47+
},
48+
"jest": {
49+
"testEnvironment": "node",
50+
"transform": {}
51+
}
52+
}
53+
```

0 commit comments

Comments
 (0)