@carecard/auth-util 3.18.0 → 3.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/carecard-workspace-standards/SKILL.md +49 -20
- package/.agents/skills/github-pr-create-update/SKILL.md +22 -1
- package/.agents/skills/github-pr-merge-cleanup/SKILL.md +22 -1
- package/.agents/skills/pkg-auth-util-auth-crypto-library/SKILL.md +40 -15
- package/.agents/skills/pkg-auth-util-coding-standards-and-best-practices/SKILL.md +42 -7
- package/.agents/skills/pkg-publish/SKILL.md +22 -1
- package/.agents/skills/software-design-patterns-and-clean-code/SKILL.md +23 -3
- package/.prettierrc.js +4 -2
- package/AGENTS.md +19 -0
- package/eslint.config.mjs +52 -0
- package/index.d.ts +30 -6
- package/index.js +2 -1
- package/lib/jwtUtilAuth.js +59 -13
- package/lib/pwdUtilAuth.js +50 -21
- package/lib/stringUtilAuth.js +9 -3
- package/lint-staged.config.mjs +36 -0
- package/package.json +19 -13
- package/readme.md +22 -1
- package/scripts/canonicalTestCommand.test.mjs +33 -0
- package/scripts/packageTaskRunner.audit.mjs +37 -0
- package/scripts/packageTaskRunner.test.mjs +4 -26
- package/scripts/runPackageTask.mjs +63 -16
- package/scripts/testOrder/randomizeTestOrder.cjs +15 -5
- package/scripts/testOrder/{testOrderPolicy.test.mjs → testOrderPolicy.audit.mjs} +8 -2
- package/scripts/testParallel/{parallelTestPolicy.test.mjs → parallelTestPolicy.audit.mjs} +28 -9
- package/scripts/testParallel/runIndexedMochaTests.cjs +3 -1
- package/scripts/testParallel/runIndexedMochaTests.test.mjs +8 -2
package/lib/jwtUtilAuth.js
CHANGED
|
@@ -1,22 +1,25 @@
|
|
|
1
1
|
const crypto = require('crypto');
|
|
2
2
|
|
|
3
|
+
const supportedVerificationHashes = new Set(crypto.getHashes().map(hash => hash.toLowerCase()));
|
|
4
|
+
|
|
5
|
+
// Pattern: Normalizer - preserves explicit registered times while supplying omitted defaults.
|
|
3
6
|
const _normalizePayload = payloadObject => {
|
|
4
7
|
const payload = { ...payloadObject };
|
|
5
8
|
const now = Math.floor(Date.now() / 1000);
|
|
6
9
|
const fieldsToNormalize = ['iat', 'exp', 'nbf', 'auth_time'];
|
|
7
10
|
const msThreshold = 1000000000000;
|
|
8
11
|
|
|
9
|
-
if (!payload.iat) {
|
|
12
|
+
if (!Number.isFinite(payload.iat)) {
|
|
10
13
|
payload.iat = now;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
fieldsToNormalize.forEach(field => {
|
|
14
|
-
if (payload[field] && payload[field] > msThreshold) {
|
|
17
|
+
if (Number.isFinite(payload[field]) && payload[field] > msThreshold) {
|
|
15
18
|
payload[field] = Math.floor(payload[field] / 1000);
|
|
16
19
|
}
|
|
17
20
|
});
|
|
18
21
|
|
|
19
|
-
if (!payload.exp) {
|
|
22
|
+
if (!Number.isFinite(payload.exp)) {
|
|
20
23
|
payload.exp = payload.iat + 3600;
|
|
21
24
|
}
|
|
22
25
|
return payload;
|
|
@@ -52,12 +55,29 @@ const _verify = (token, signature, alg, publicKey) => {
|
|
|
52
55
|
return verify.verify(publicKey, signature, 'base64url');
|
|
53
56
|
};
|
|
54
57
|
|
|
58
|
+
// Pattern: Allow List - rejects algorithms that the runtime cannot safely verify.
|
|
59
|
+
const isSupportedVerificationAlgorithm = algorithm => {
|
|
60
|
+
if (algorithm === 'EdDSA' || algorithm === 'Ed25519') {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
return typeof algorithm === 'string' && supportedVerificationHashes.has(algorithm.toLowerCase());
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Pattern: Type Guard - decoded JWT parts must honor their documented object contract.
|
|
67
|
+
const isDecodedJwtObject = value => {
|
|
68
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
69
|
+
};
|
|
70
|
+
|
|
55
71
|
const isNonEmptyString = value => {
|
|
56
72
|
return typeof value === 'string' && value.trim().length > 0;
|
|
57
73
|
};
|
|
58
74
|
|
|
59
75
|
const audienceIsNonEmptyStringArray = audience => {
|
|
60
|
-
return
|
|
76
|
+
return (
|
|
77
|
+
Array.isArray(audience) &&
|
|
78
|
+
audience.length > 0 &&
|
|
79
|
+
audience.every(audienceValue => isNonEmptyString(audienceValue))
|
|
80
|
+
);
|
|
61
81
|
};
|
|
62
82
|
|
|
63
83
|
const isNonEmptyAudience = audience => {
|
|
@@ -65,7 +85,9 @@ const isNonEmptyAudience = audience => {
|
|
|
65
85
|
};
|
|
66
86
|
|
|
67
87
|
const normalizeSeconds = value => {
|
|
68
|
-
if (!Number.isFinite(value))
|
|
88
|
+
if (!Number.isFinite(value)) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
69
91
|
return value > 1000000000000 ? Math.floor(value / 1000) : Math.floor(value);
|
|
70
92
|
};
|
|
71
93
|
|
|
@@ -77,7 +99,9 @@ const normalizeSeconds = value => {
|
|
|
77
99
|
* @return {string|null}
|
|
78
100
|
*/
|
|
79
101
|
const createSignedJwtFromObject = (headerObject, payloadObject, privateKey) => {
|
|
80
|
-
if (!privateKey)
|
|
102
|
+
if (!privateKey) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
81
105
|
|
|
82
106
|
const header = { ...headerObject };
|
|
83
107
|
header.alg = header.alg || 'EdDSA';
|
|
@@ -116,7 +140,9 @@ function createServiceJwt({
|
|
|
116
140
|
}
|
|
117
141
|
|
|
118
142
|
const iat = normalizeSeconds(issuedAt);
|
|
119
|
-
if (!Number.isInteger(iat))
|
|
143
|
+
if (!Number.isInteger(iat)) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
120
146
|
|
|
121
147
|
// Service-to-service tokens use registered JWT claims so receivers can
|
|
122
148
|
// validate sender, intended audience, subject, and lifetime consistently.
|
|
@@ -145,19 +171,29 @@ function createServiceAuthorizationHeader(options = {}) {
|
|
|
145
171
|
* @param publicKey
|
|
146
172
|
* @return {boolean}
|
|
147
173
|
*/
|
|
174
|
+
// Pattern: Fail-Closed Verification - malformed token metadata returns false before crypto use.
|
|
148
175
|
const verifyJwtSignature = (jwt, publicKey) => {
|
|
149
176
|
try {
|
|
150
|
-
if (!jwt || !publicKey)
|
|
177
|
+
if (!jwt || !publicKey) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
151
180
|
const splitJWT = jwt.split('.');
|
|
152
|
-
if (splitJWT.length !== 3)
|
|
181
|
+
if (splitJWT.length !== 3) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
153
184
|
|
|
154
185
|
const [header, payload, signature] = splitJWT;
|
|
155
186
|
const token = header + '.' + payload;
|
|
156
187
|
const headerObject = _decode(header);
|
|
188
|
+
if (!isDecodedJwtObject(headerObject) || !isSupportedVerificationAlgorithm(headerObject.alg)) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
157
191
|
|
|
158
192
|
return _verify(token, signature, headerObject.alg, publicKey);
|
|
159
193
|
} catch (error) {
|
|
160
|
-
if (!(error instanceof SyntaxError))
|
|
194
|
+
if (!(error instanceof SyntaxError)) {
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
161
197
|
return false;
|
|
162
198
|
}
|
|
163
199
|
};
|
|
@@ -167,18 +203,28 @@ const verifyJwtSignature = (jwt, publicKey) => {
|
|
|
167
203
|
* @param jwt
|
|
168
204
|
* @return {{payload: *, header: *}|null}
|
|
169
205
|
*/
|
|
206
|
+
// Pattern: Boundary Validation - returns only decoded header and payload objects.
|
|
170
207
|
const getHeaderPayloadFromJwt = jwt => {
|
|
171
208
|
try {
|
|
172
|
-
if (typeof jwt !== 'string')
|
|
209
|
+
if (typeof jwt !== 'string') {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
173
212
|
const splitJWT = jwt.split('.');
|
|
174
|
-
if (splitJWT.length !== 3)
|
|
213
|
+
if (splitJWT.length !== 3) {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
175
216
|
|
|
176
217
|
const headerObject = _decode(splitJWT[0]);
|
|
177
218
|
const payloadObject = _decode(splitJWT[1]);
|
|
219
|
+
if (!isDecodedJwtObject(headerObject) || !isDecodedJwtObject(payloadObject)) {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
178
222
|
|
|
179
223
|
return { header: headerObject, payload: payloadObject };
|
|
180
224
|
} catch (error) {
|
|
181
|
-
if (!(error instanceof SyntaxError))
|
|
225
|
+
if (!(error instanceof SyntaxError)) {
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
182
228
|
return null;
|
|
183
229
|
}
|
|
184
230
|
};
|
package/lib/pwdUtilAuth.js
CHANGED
|
@@ -1,38 +1,67 @@
|
|
|
1
1
|
const crypto = require('crypto');
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
3
|
+
const PASSWORD_HASH_VERSION = '1';
|
|
4
|
+
|
|
5
|
+
// Pattern: Pure Function - creates a digest in which both the pepper and random salt participate.
|
|
6
|
+
function createSaltedPasswordDigest(password, secret, algorithm, salt) {
|
|
7
|
+
return crypto
|
|
8
|
+
.createHmac(algorithm, secret)
|
|
9
|
+
.update(Buffer.from(salt, 'base64'))
|
|
10
|
+
.update(password)
|
|
11
|
+
.digest('base64');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Pattern: Pure Function - recreates the historical digest for existing stored credentials.
|
|
15
|
+
function createLegacyPasswordDigest(password, secret, algorithm) {
|
|
16
|
+
return crypto.createHmac(algorithm, secret).update(password).digest('base64');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Pattern: Serializer - preserves the published password-hash storage contract.
|
|
20
|
+
function serializePasswordHash(algorithmBase64, hashBase64, salt) {
|
|
21
|
+
return `$${PASSWORD_HASH_VERSION}$${algorithmBase64}$${hashBase64}$${salt}$`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Pattern: Constant-Time Comparison - avoids credential-dependent string comparison timing.
|
|
25
|
+
function passwordHashesMatch(candidateHash, savedPasswordHash) {
|
|
26
|
+
const candidateBuffer = Buffer.from(candidateHash);
|
|
27
|
+
const savedBuffer = Buffer.from(savedPasswordHash);
|
|
28
|
+
return (
|
|
29
|
+
candidateBuffer.length === savedBuffer.length &&
|
|
30
|
+
crypto.timingSafeEqual(candidateBuffer, savedBuffer)
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Pattern: Factory - creates a password hash whose random salt changes the credential digest.
|
|
10
35
|
const createPasswordHashWithRandomSalt = (password, secret, algorithm) => {
|
|
11
36
|
const salt = crypto.randomBytes(32).toString('base64');
|
|
12
37
|
const algorithmBase64 = Buffer.from(algorithm).toString('base64');
|
|
13
|
-
const hashBase64 =
|
|
14
|
-
return
|
|
38
|
+
const hashBase64 = createSaltedPasswordDigest(password, secret, algorithm, salt);
|
|
39
|
+
return serializePasswordHash(algorithmBase64, hashBase64, salt);
|
|
15
40
|
};
|
|
16
41
|
|
|
17
|
-
|
|
18
|
-
* Creates hash based on saved hash in database.
|
|
19
|
-
* @param password
|
|
20
|
-
* @param savedPasswordHash
|
|
21
|
-
* @param secret
|
|
22
|
-
* @return {string}
|
|
23
|
-
*/
|
|
42
|
+
// Pattern: Compatibility Verification - verifies salted hashes while retaining existing credentials.
|
|
24
43
|
const createPasswordHashBasedOnSavedAlgorithmSalt = (password, savedPasswordHash, secret) => {
|
|
25
|
-
if (typeof savedPasswordHash !== 'string')
|
|
44
|
+
if (typeof savedPasswordHash !== 'string') {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
26
47
|
|
|
27
48
|
const splitStringArray = savedPasswordHash.split('$');
|
|
28
|
-
if (splitStringArray.length !== 6)
|
|
49
|
+
if (splitStringArray.length !== 6) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
29
52
|
|
|
53
|
+
const version = splitStringArray[1];
|
|
30
54
|
const algBase64 = splitStringArray[2];
|
|
31
55
|
const salt = splitStringArray[4];
|
|
56
|
+
if (version !== PASSWORD_HASH_VERSION || !algBase64 || !salt) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
32
59
|
const algorithm = Buffer.from(algBase64, 'base64').toString('utf8');
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
60
|
+
const saltedDigest = createSaltedPasswordDigest(password, secret, algorithm, salt);
|
|
61
|
+
const saltedHash = serializePasswordHash(algBase64, saltedDigest, salt);
|
|
62
|
+
const legacyDigest = createLegacyPasswordDigest(password, secret, algorithm);
|
|
63
|
+
const legacyHash = serializePasswordHash(algBase64, legacyDigest, salt);
|
|
64
|
+
return passwordHashesMatch(legacyHash, savedPasswordHash) ? legacyHash : saltedHash;
|
|
36
65
|
};
|
|
37
66
|
|
|
38
67
|
module.exports = {
|
package/lib/stringUtilAuth.js
CHANGED
|
@@ -60,7 +60,9 @@ const base64ToAscii = codedString => {
|
|
|
60
60
|
*/
|
|
61
61
|
const dollarSignConnectedStringToAlgorithmHashSalt = passwordHash => {
|
|
62
62
|
const splitStringArray = passwordHash.split('$');
|
|
63
|
-
if (splitStringArray.length !== 6)
|
|
63
|
+
if (splitStringArray.length !== 6) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
64
66
|
return {
|
|
65
67
|
version: splitStringArray[1],
|
|
66
68
|
alg: splitStringArray[2],
|
|
@@ -76,9 +78,13 @@ const dollarSignConnectedStringToAlgorithmHashSalt = passwordHash => {
|
|
|
76
78
|
* @deprecated Use native Buffer methods or other modern alternatives.
|
|
77
79
|
*/
|
|
78
80
|
const dotConnectedStringToHeaderPayloadSignature = jwt => {
|
|
79
|
-
if (typeof jwt !== 'string')
|
|
81
|
+
if (typeof jwt !== 'string') {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
80
84
|
const splitJWT = jwt.split('.');
|
|
81
|
-
if (splitJWT.length !== 3)
|
|
85
|
+
if (splitJWT.length !== 3) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
82
88
|
return {
|
|
83
89
|
header: splitJWT[0],
|
|
84
90
|
payload: splitJWT[1],
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ESLint } from 'eslint';
|
|
2
|
+
|
|
3
|
+
const eslint = new ESLint();
|
|
4
|
+
|
|
5
|
+
// Pattern: Pure Function - builds one deterministic command without shell interpolation.
|
|
6
|
+
const createCommand = (command, filePaths) =>
|
|
7
|
+
`${command} ${filePaths.map(filePath => JSON.stringify(filePath)).join(' ')}`;
|
|
8
|
+
|
|
9
|
+
// Pattern: Adapter - derives lint-staged input from ESLint's authoritative ignore rules.
|
|
10
|
+
const removeEslintIgnoredFiles = async filePaths => {
|
|
11
|
+
const ignoredFileStates = await Promise.all(
|
|
12
|
+
filePaths.map(filePath => eslint.isPathIgnored(filePath)),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
return filePaths.flatMap((filePath, index) => (ignoredFileStates[index] ? [] : [filePath]));
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Pattern: Pipeline - preserves ESLint-before-Prettier ordering for staged code.
|
|
19
|
+
const createJavaScriptTasks = async filePaths => {
|
|
20
|
+
const lintableFilePaths = await removeEslintIgnoredFiles(filePaths);
|
|
21
|
+
const tasks = [];
|
|
22
|
+
|
|
23
|
+
if (lintableFilePaths.length > 0) {
|
|
24
|
+
tasks.push(createCommand('eslint --fix --max-warnings 0', lintableFilePaths));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
tasks.push(createCommand('prettier --write', filePaths));
|
|
28
|
+
return tasks;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const lintStagedConfig = {
|
|
32
|
+
'*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}': createJavaScriptTasks,
|
|
33
|
+
'*.{json,jsonc,md,mdx,css,scss,yaml,yml}': ['prettier --write'],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export default lintStagedConfig;
|
package/package.json
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carecard/auth-util",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.20.0",
|
|
4
4
|
"repository": "https://github.com/CareCard-ca/pkg-auth-util.git",
|
|
5
5
|
"description": "Auth utility functions",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"types": "index.d.ts",
|
|
8
8
|
"scripts": {
|
|
9
9
|
"test": "node scripts/runPackageTask.mjs test",
|
|
10
|
-
"test:order": "node --test scripts/testOrder/randomizeTestOrder.test.mjs scripts/
|
|
10
|
+
"test:order": "node --test scripts/testOrder/randomizeTestOrder.test.mjs scripts/testParallel/runIndexedMochaTests.test.mjs scripts/packageTaskRunner.test.mjs scripts/canonicalTestCommand.test.mjs",
|
|
11
11
|
"test:types": "node scripts/runPackageTask.mjs test:types",
|
|
12
12
|
"test:coverage": "node scripts/runPackageTask.mjs test:coverage",
|
|
13
13
|
"test:All": "node scripts/runPackageTask.mjs test:All",
|
|
14
|
-
"
|
|
15
|
-
"format
|
|
14
|
+
"validate:audits": "node scripts/runPackageTask.mjs validate:audits",
|
|
15
|
+
"format": "prettier --write \"**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}\"",
|
|
16
|
+
"format:check": "prettier --check \"**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}\"",
|
|
17
|
+
"lint-staged": "lint-staged",
|
|
16
18
|
"prepare": "husky",
|
|
17
|
-
"lint:fix": "eslint --fix",
|
|
18
|
-
"lint": "eslint"
|
|
19
|
+
"lint:fix": "eslint . --fix --max-warnings 0",
|
|
20
|
+
"lint": "eslint . --max-warnings 0"
|
|
19
21
|
},
|
|
20
22
|
"keywords": [
|
|
21
23
|
"auth",
|
|
@@ -26,31 +28,35 @@
|
|
|
26
28
|
"author": "CareCard team",
|
|
27
29
|
"license": "ISC",
|
|
28
30
|
"devDependencies": {
|
|
31
|
+
"@eslint/js": "9.39.5",
|
|
29
32
|
"@istanbuljs/nyc-config-typescript": "1.0.2",
|
|
30
33
|
"@types/mocha": "10.0.10",
|
|
31
34
|
"@types/node": "25.9.3",
|
|
32
|
-
"eslint": "
|
|
35
|
+
"@typescript-eslint/parser": "8.67.0",
|
|
36
|
+
"eslint": "9.39.5",
|
|
33
37
|
"express": "5.2.1",
|
|
38
|
+
"globals": "17.7.0",
|
|
34
39
|
"husky": "9.1.7",
|
|
35
|
-
"lint-staged": "17.0
|
|
40
|
+
"lint-staged": "17.2.0",
|
|
36
41
|
"mocha": "11.7.6",
|
|
37
42
|
"nyc": "18.0.0",
|
|
38
|
-
"prettier": "3.
|
|
43
|
+
"prettier": "3.9.6",
|
|
39
44
|
"source-map-support": "0.5.21",
|
|
40
45
|
"supertest": "7.2.2",
|
|
41
46
|
"ts-node": "10.9.2",
|
|
42
47
|
"typescript": "6.0.3"
|
|
43
48
|
},
|
|
44
49
|
"dependencies": {
|
|
45
|
-
"@carecard/common-util": "3.
|
|
46
|
-
"@carecard/validate": "3.
|
|
50
|
+
"@carecard/common-util": "3.20.0",
|
|
51
|
+
"@carecard/validate": "3.20.0",
|
|
47
52
|
"@types/express": "5.0.6"
|
|
48
53
|
},
|
|
49
54
|
"overrides": {
|
|
50
55
|
"diff": "8.0.4",
|
|
51
56
|
"glob": "13.0.6",
|
|
52
|
-
"
|
|
57
|
+
"brace-expansion": "5.0.9",
|
|
58
|
+
"minimatch": "10.2.6",
|
|
53
59
|
"serialize-javascript": "7.0.5",
|
|
54
|
-
"js-yaml": "4.3.
|
|
60
|
+
"js-yaml": "4.3.1"
|
|
55
61
|
}
|
|
56
62
|
}
|
package/readme.md
CHANGED
|
@@ -11,7 +11,9 @@ Utility package for authentication and authorization in the CareCard ecosystem.
|
|
|
11
11
|
|
|
12
12
|
## Development Rule
|
|
13
13
|
|
|
14
|
-
Non-negotiable TDD rule: Always write the failing test first, run it to confirm it fails for the intended reason, then implement the code and rerun the test until it passes. Test Driven Development is required for all coding work and must not be skipped. For documentation- or skill-only edits,
|
|
14
|
+
Non-negotiable TDD rule: Always write the failing test first, run it to confirm it fails for the intended reason, then implement the code and rerun the test until it passes. Test Driven Development is required for all coding work and must not be skipped. For documentation- or skill-only edits, run the relevant focused non-test
|
|
15
|
+
validation before changing the prose; do not add automated tests that inspect
|
|
16
|
+
prose, files, or repository structure.
|
|
15
17
|
|
|
16
18
|
Non-negotiable repository isolation rule: Every repository must run its Husky hooks and tests using only files, code, fixtures, dependencies, and services contained within that repository. Tests and Husky scripts must not import, require, read, execute, or otherwise depend on sibling repositories or paths outside the repository root. app-e2e-tests is the only exception because cross-repository end-to-end testing is its explicit responsibility.
|
|
17
19
|
|
|
@@ -172,3 +174,22 @@ immediately when no helper remains, allow only a bounded 250 ms settlement
|
|
|
172
174
|
window for already-stopping helpers, fail persistent descendants, preserve
|
|
173
175
|
failures and output, use exit code `124` only for a real outer deadline, and
|
|
174
176
|
remain a final guard rather than a substitute for explicit cleanup.
|
|
177
|
+
|
|
178
|
+
## TDD And Validation
|
|
179
|
+
|
|
180
|
+
Test Driven Development is a non-negotiable requirement.
|
|
181
|
+
|
|
182
|
+
The sole purpose of automated tests is to verify observable functionality and externally visible behavior.
|
|
183
|
+
Tests must validate what the system does through its public interfaces and expected outcomes.
|
|
184
|
+
|
|
185
|
+
Tests must not assert, inspect, or depend on implementation details, including but not limited to:
|
|
186
|
+
|
|
187
|
+
- The existence of specific lines of code, statements, functions, classes, files, or modules.
|
|
188
|
+
- Specific algorithms, control flow, variable names, method calls, code snippets, or internal implementation choices.
|
|
189
|
+
- Any internal structure that can change without changing externally observable behavior.
|
|
190
|
+
|
|
191
|
+
A correct implementation may be completely rewritten or refactored without requiring changes to functional tests, provided its externally observable behavior remains unchanged.
|
|
192
|
+
|
|
193
|
+
Any test that fails solely because the implementation changed while the externally observable behavior remained correct is incorrectly designed and must be rewritten or removed.
|
|
194
|
+
|
|
195
|
+
This requirement is mandatory for all new tests and must be applied whenever existing tests are modified.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { runPackageTask } from './runPackageTask.mjs';
|
|
5
|
+
|
|
6
|
+
test('the complete test command runs each validation category exactly once', () => {
|
|
7
|
+
const executedSteps = [];
|
|
8
|
+
|
|
9
|
+
const exitCode = runPackageTask('test', taskStep => {
|
|
10
|
+
executedSteps.push([taskStep.command, ...(taskStep.arguments ?? [])].join(' '));
|
|
11
|
+
return 0;
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
assert.equal(exitCode, 0);
|
|
15
|
+
assert.deepEqual(executedSteps, [
|
|
16
|
+
'npm run validate:audits',
|
|
17
|
+
'npm run test:order',
|
|
18
|
+
'tsc --noEmit',
|
|
19
|
+
'mocha --require ./scripts/testOrder/randomizeTestOrder.cjs -r ts-node/register test/types.test.ts',
|
|
20
|
+
'nyc node test/index.test.js',
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('the legacy aggregate command delegates to the complete test command once', () => {
|
|
25
|
+
const executedSteps = [];
|
|
26
|
+
|
|
27
|
+
runPackageTask('test:All', taskStep => {
|
|
28
|
+
executedSteps.push([taskStep.command, ...(taskStep.arguments ?? [])].join(' '));
|
|
29
|
+
return 0;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
assert.deepEqual(executedSteps, ['npm test']);
|
|
33
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import test from 'node:test';
|
|
4
|
+
|
|
5
|
+
const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
6
|
+
const composedCommandPattern = /&&|\|\||\bsh -c\b|\bnode -e\b/u;
|
|
7
|
+
const runnerCommandPattern = /^node scripts\/runPackageTask\.mjs ([A-Za-z0-9:_-]+)$/u;
|
|
8
|
+
|
|
9
|
+
async function loadPackageTaskRunner() {
|
|
10
|
+
return import(new URL('./runPackageTask.mjs', import.meta.url));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
test('delegates composed package tasks to the repository runner', async () => {
|
|
14
|
+
const runnerCommands = Object.entries(packageJson.scripts ?? {}).filter(([, command]) =>
|
|
15
|
+
runnerCommandPattern.test(command),
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
for (const [scriptName, command] of Object.entries(packageJson.scripts ?? {})) {
|
|
19
|
+
assert.doesNotMatch(
|
|
20
|
+
command,
|
|
21
|
+
composedCommandPattern,
|
|
22
|
+
`${scriptName} must delegate composition to runPackageTask.mjs`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
assert.ok(runnerCommands.length > 0, 'at least one package task must use the runner');
|
|
27
|
+
const { packageTasks } = await loadPackageTaskRunner();
|
|
28
|
+
for (const [scriptName] of runnerCommands) {
|
|
29
|
+
assert.ok(packageTasks[scriptName], `${scriptName} must have a runner task`);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('does not suppress command execution errors at the CLI boundary', () => {
|
|
34
|
+
const runnerSource = readFileSync(new URL('./runPackageTask.mjs', import.meta.url), 'utf8');
|
|
35
|
+
|
|
36
|
+
assert.doesNotMatch(runnerSource, /catch\s*\{/u);
|
|
37
|
+
});
|
|
@@ -1,29 +1,10 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
3
2
|
import test from 'node:test';
|
|
4
3
|
|
|
5
|
-
const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
6
|
-
const composedCommandPattern = /&&|\|\||\bsh -c\b|\bnode -e\b/u;
|
|
7
|
-
const runnerCommandPattern = /^node scripts\/runPackageTask\.mjs ([A-Za-z0-9:_-]+)$/u;
|
|
8
|
-
|
|
9
4
|
async function loadPackageTaskRunner() {
|
|
10
5
|
return import(new URL('./runPackageTask.mjs', import.meta.url));
|
|
11
6
|
}
|
|
12
7
|
|
|
13
|
-
test('delegates composed package tasks to the repository runner', async () => {
|
|
14
|
-
const runnerCommands = Object.entries(packageJson.scripts ?? {}).filter(([, command]) => runnerCommandPattern.test(command));
|
|
15
|
-
|
|
16
|
-
for (const [scriptName, command] of Object.entries(packageJson.scripts ?? {})) {
|
|
17
|
-
assert.doesNotMatch(command, composedCommandPattern, `${scriptName} must delegate composition to runPackageTask.mjs`);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
assert.ok(runnerCommands.length > 0, 'at least one package task must use the runner');
|
|
21
|
-
const { packageTasks } = await loadPackageTaskRunner();
|
|
22
|
-
for (const [scriptName] of runnerCommands) {
|
|
23
|
-
assert.ok(packageTasks[scriptName], `${scriptName} must have a runner task`);
|
|
24
|
-
}
|
|
25
|
-
});
|
|
26
|
-
|
|
27
8
|
test('runs task steps in order and stops at the first failure', async () => {
|
|
28
9
|
const { runPackageTask } = await loadPackageTaskRunner();
|
|
29
10
|
const executedCommands = [];
|
|
@@ -65,17 +46,14 @@ test('runs conditional steps only when their required output is missing', async
|
|
|
65
46
|
assert.deepEqual(executedCommands, ['execute']);
|
|
66
47
|
});
|
|
67
48
|
|
|
68
|
-
test('does not suppress command execution errors at the CLI boundary', () => {
|
|
69
|
-
const runnerSource = readFileSync(new URL('./runPackageTask.mjs', import.meta.url), 'utf8');
|
|
70
|
-
|
|
71
|
-
assert.doesNotMatch(runnerSource, /catch\s*\{/u);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
49
|
test('merges task environment overrides without mutating inherited values', async () => {
|
|
75
50
|
const { createTaskEnvironment } = await loadPackageTaskRunner();
|
|
76
51
|
const inheritedEnvironment = { NODE_ENV: 'test', PATH: '/bin' };
|
|
77
52
|
|
|
78
|
-
const environment = createTaskEnvironment(
|
|
53
|
+
const environment = createTaskEnvironment(
|
|
54
|
+
{ NODE_ENV: 'production', DB_ENV: 'privileged' },
|
|
55
|
+
inheritedEnvironment,
|
|
56
|
+
);
|
|
79
57
|
|
|
80
58
|
assert.deepEqual(environment, {
|
|
81
59
|
NODE_ENV: 'production',
|
|
@@ -4,27 +4,51 @@ import { resolve } from 'node:path';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
|
|
6
6
|
export const packageTasks = Object.freeze({
|
|
7
|
+
'validate:audits': [
|
|
8
|
+
{ command: 'node', arguments: ['scripts/packageTaskRunner.audit.mjs'] },
|
|
9
|
+
{ command: 'node', arguments: ['scripts/testOrder/testOrderPolicy.audit.mjs'] },
|
|
10
|
+
{ command: 'node', arguments: ['scripts/testParallel/parallelTestPolicy.audit.mjs'] },
|
|
11
|
+
{ command: 'mocha', arguments: ['test/config/repositoryIsolation.audit.js'] },
|
|
12
|
+
{ command: 'mocha', arguments: ['test/config/tddGuidanceDocs.audit.js'] },
|
|
13
|
+
{ command: 'mocha', arguments: ['test/dependencyOverrides.audit.js'] },
|
|
14
|
+
],
|
|
7
15
|
test: [
|
|
16
|
+
{ command: 'npm', arguments: ['run', 'validate:audits'] },
|
|
8
17
|
{ command: 'npm', arguments: ['run', 'test:order'] },
|
|
9
|
-
{ command: '
|
|
18
|
+
{ command: 'tsc', arguments: ['--noEmit'] },
|
|
19
|
+
{
|
|
20
|
+
command: 'mocha',
|
|
21
|
+
arguments: [
|
|
22
|
+
'--require',
|
|
23
|
+
'./scripts/testOrder/randomizeTestOrder.cjs',
|
|
24
|
+
'-r',
|
|
25
|
+
'ts-node/register',
|
|
26
|
+
'test/types.test.ts',
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
{ command: 'nyc', arguments: ['node', 'test/index.test.js'] },
|
|
10
30
|
],
|
|
11
31
|
'test:types': [
|
|
12
32
|
{ command: 'npm', arguments: ['run', 'test:order'] },
|
|
13
33
|
{ command: 'tsc', arguments: ['--noEmit'] },
|
|
14
34
|
{
|
|
15
35
|
command: 'mocha',
|
|
16
|
-
arguments: [
|
|
36
|
+
arguments: [
|
|
37
|
+
'--require',
|
|
38
|
+
'./scripts/testOrder/randomizeTestOrder.cjs',
|
|
39
|
+
'-r',
|
|
40
|
+
'ts-node/register',
|
|
41
|
+
'test/types.test.ts',
|
|
42
|
+
],
|
|
17
43
|
},
|
|
18
44
|
],
|
|
19
45
|
'test:coverage': [
|
|
46
|
+
{ command: 'npm', arguments: ['run', 'validate:audits'] },
|
|
20
47
|
{ command: 'npm', arguments: ['run', 'test:order'] },
|
|
21
48
|
{ command: 'tsc', arguments: ['--noEmit'] },
|
|
22
49
|
{ command: 'nyc', arguments: ['node', 'test/index.test.js'] },
|
|
23
50
|
],
|
|
24
|
-
'test:All': [
|
|
25
|
-
{ command: 'npm', arguments: ['run', 'test'] },
|
|
26
|
-
{ command: 'npm', arguments: ['run', 'test:types'] },
|
|
27
|
-
],
|
|
51
|
+
'test:All': [{ command: 'npm', arguments: ['test'] }],
|
|
28
52
|
});
|
|
29
53
|
|
|
30
54
|
export function createTaskEnvironment(overrides = {}, inheritedEnvironment = process.env) {
|
|
@@ -32,10 +56,18 @@ export function createTaskEnvironment(overrides = {}, inheritedEnvironment = pro
|
|
|
32
56
|
}
|
|
33
57
|
|
|
34
58
|
function getTaskExitCode(result) {
|
|
35
|
-
if (result.error)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (result.
|
|
59
|
+
if (result.error) {
|
|
60
|
+
throw result.error;
|
|
61
|
+
}
|
|
62
|
+
if (typeof result.status === 'number') {
|
|
63
|
+
return result.status;
|
|
64
|
+
}
|
|
65
|
+
if (result.signal === 'SIGINT') {
|
|
66
|
+
return 130;
|
|
67
|
+
}
|
|
68
|
+
if (result.signal === 'SIGTERM') {
|
|
69
|
+
return 143;
|
|
70
|
+
}
|
|
39
71
|
return 1;
|
|
40
72
|
}
|
|
41
73
|
|
|
@@ -57,20 +89,33 @@ function shouldRunTaskStep(taskStep, pathExists) {
|
|
|
57
89
|
return !taskStep.whenMissing || !pathExists(taskStep.whenMissing);
|
|
58
90
|
}
|
|
59
91
|
|
|
60
|
-
export function runPackageTask(
|
|
92
|
+
export function runPackageTask(
|
|
93
|
+
taskName,
|
|
94
|
+
executeTask = executeTaskStep,
|
|
95
|
+
taskDefinitions = packageTasks,
|
|
96
|
+
pathExists = existsSync,
|
|
97
|
+
) {
|
|
61
98
|
const taskSteps = taskDefinitions[taskName];
|
|
62
|
-
if (!Array.isArray(taskSteps))
|
|
99
|
+
if (!Array.isArray(taskSteps)) {
|
|
100
|
+
throw new Error('Unknown package task.');
|
|
101
|
+
}
|
|
63
102
|
|
|
64
103
|
for (const taskStep of taskSteps) {
|
|
65
|
-
if (!shouldRunTaskStep(taskStep, pathExists))
|
|
104
|
+
if (!shouldRunTaskStep(taskStep, pathExists)) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
66
107
|
const exitCode = executeTask(taskStep);
|
|
67
|
-
if (exitCode !== 0)
|
|
108
|
+
if (exitCode !== 0) {
|
|
109
|
+
return exitCode;
|
|
110
|
+
}
|
|
68
111
|
}
|
|
69
112
|
return 0;
|
|
70
113
|
}
|
|
71
114
|
|
|
72
115
|
function isDirectExecution() {
|
|
73
|
-
if (!process.argv[1])
|
|
116
|
+
if (!process.argv[1]) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
74
119
|
return resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
75
120
|
}
|
|
76
121
|
|
|
@@ -85,4 +130,6 @@ function runCommandLineTask() {
|
|
|
85
130
|
process.exitCode = runPackageTask(taskName);
|
|
86
131
|
}
|
|
87
132
|
|
|
88
|
-
if (isDirectExecution())
|
|
133
|
+
if (isDirectExecution()) {
|
|
134
|
+
runCommandLineTask();
|
|
135
|
+
}
|