From 3cd68ba82ca4b88493faf731798802878d53fb73 Mon Sep 17 00:00:00 2001 From: Sean Dolbec Date: Mon, 2 Nov 2026 14:09:02 -0500 Subject: [PATCH 1/5] Resolve all 15 Dependabot alerts in mobile build tooling - node-forge 1.4.0 -> 1.4.2 (lockfile only, within Expo's ^1.4.0): fixes 12 advisories, including GHSA-86w9 via canonical DigestInfo checks, so the local RSA backport is retired. - overrides: glob 14.0.3 (GHSA-3g3m) and minimatch 11.0.3 (GHSA-g3hv). Expo SDK 58 still declares glob ^13 / minimatch ^10, which have no fix. Breaking API changes checked against every Expo call site. - deepmerge 4.3.1 has no upstream fix: source-hashed backport so getKeys() never returns an own __proto__ key (GHSA-gcqx). - Regression tests for fixed versions, deepmerge poisoning, and Expo's glob/fingerprint matching under the new majors. Restores the fail-closed audit:security gate, which fails on main since the glob advisory was published. Build-time tooling only; nothing here ships in the app bundle. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + mobile/README.md | 4 +- mobile/package-lock.json | 65 ++++++++++++++++------ mobile/package.json | 2 + mobile/scripts/security-backports.mjs | 2 +- mobile/security/README.md | 9 ++-- mobile/security/backports.json | 20 +++---- mobile/tests/dependency-security.test.mjs | 66 +++++++++++++++++++++++ 8 files changed, 138 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d5cf0bcc..b9907744c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixed +- Resolve all 15 open Dependabot alerts in the employee mobile app's build tooling. node-forge 1.4.2 (lockfile only) fixes 12 advisories, including the one the local RSA backport covered, so that backport is removed. npm `overrides` move Expo's glob to 14.0.3 and minimatch to 11.0.3. deepmerge 4.3.1 has no upstream fix and gets a source-hashed backport that blocks `__proto__` poisoning. None of these packages ship in the app bundle. - Enforce upload inbox quotas under SQLite's writer lock, including rejected files, and require each retained original to fit the remaining storage allowance. Identical-file duplicates consume no additional retained bytes. - Align the employee mobile app with Expo SDK 58's supported dependency set: Expo 58.0.17 and TypeScript 6.0.3, preserving React 19.3.0, React Native 0.88.0 and the verified security backports. diff --git a/mobile/README.md b/mobile/README.md index 5cb11a5ab..974193efc 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -48,9 +48,9 @@ After acceptance, share the approved native distribution links through the organ Use designated test accounts on an HTTPS staging instance. Test real iPhone and Android builds for login, revoked/expired sessions, cold starts, background/foreground, clock-in/out retries, draft creation, entries, import, submit, rejected-sheet correction, and sign-out. Reviewers use the web review workspace. Check poor connectivity: an unconfirmed mutation should be refreshed and verified before retrying. The UI never claims an offline entry was saved. -The SDK 58.0.17 dependency tree removes the transitive UUID dependency through `xcode` 4.0.0, which uses Node's native UUID generator; the older scoped UUID override is no longer needed. The zod advisory (GHSA-mrhx-c7xc-m3w5) is fixed by an npm `overrides` entry that moves `@expo/cli`'s build-time zod to the patched 4.6.7 release. The remaining two underlying advisories have no published fixed npm version: `braces` 3.0.3 and `node-forge` 1.4.0 receive narrow, source-hashed backports during `npm ci`. Expo and React Native keep their supported versions. See [security backports](security/README.md) for provenance, regression coverage and removal instructions. +The SDK 58.0.17 dependency tree removes the transitive UUID dependency through `xcode` 4.0.0, which uses Node's native UUID generator; the older scoped UUID override is no longer needed. The zod advisory (GHSA-mrhx-c7xc-m3w5) is fixed by an npm `overrides` entry that moves `@expo/cli`'s build-time zod to the patched 4.6.7 release. `node-forge` resolves the fixed 1.4.2 release, and `overrides` move Expo's build-time `glob` and `minimatch` to the fixed 14.0.3 and 11.0.3 releases. The remaining two underlying advisories have no published fixed npm version: `braces` 3.0.3 and `deepmerge` 4.3.1 receive narrow, source-hashed backports during `npm ci`. Expo and React Native keep their supported versions. See [security backports](security/README.md) for provenance, regression coverage and removal instructions. -`npm run check` verifies the actual patched files and rejects any new/unexplained advisory. Raw `npm audit` still reports high findings (4 at the time of writing: `node-forge` and its dependents `@expo/code-signing-certificates`, `@expo/cli` and `expo`) because it identifies upstream versions; the report is retained in `dist/dependency-security.json` with the verified backports. These are not hidden findings or a claim that the registry audit is clean. Run the checks again immediately before signing, and complete real-device acceptance. +`npm run check` verifies the actual patched files and rejects any new/unexplained advisory. Raw `npm audit` still reports the backported `braces` and `deepmerge` findings and their dependents because it identifies upstream versions; the report is retained in `dist/dependency-security.json` with the verified backports. These are not hidden findings or a claim that the registry audit is clean. Run the checks again immediately before signing, and complete real-device acceptance. See [staff timesheets](../docs/employee-timesheets.md) for account provisioning, web installation, server deployment, and the full API contract. diff --git a/mobile/package-lock.json b/mobile/package-lock.json index cc4f56f46..6d949c822 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -1360,6 +1360,12 @@ "excpretty": "build/cli.js" } }, + "node_modules/@isaacs/nonrecursive-glob-match": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.0.3.tgz", + "integrity": "sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==", + "license": "BlueOak-1.0.0" + }, "node_modules/@isaacs/ttlcache": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", @@ -1844,6 +1850,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1860,6 +1867,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1876,6 +1884,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1892,6 +1901,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1908,6 +1918,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1924,6 +1935,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1940,6 +1952,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1956,6 +1969,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1972,6 +1986,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1988,6 +2003,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2004,6 +2020,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2020,6 +2037,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2036,6 +2054,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2052,6 +2071,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2068,6 +2088,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2084,6 +2105,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2100,6 +2122,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2116,6 +2139,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2132,6 +2156,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2148,6 +2173,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2381,6 +2407,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", "engines": { "node": "18 || 20 || >=22" } @@ -2448,6 +2475,7 @@ "version": "5.0.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, @@ -3698,13 +3726,14 @@ } }, "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-14.0.3.tgz", + "integrity": "sha512-A1vjIwC3qpdCso45Iw127gnp7sPhqgY8lUQhDrArIs+GbmfaaVPFuoyy7WyLT9EPZCRZ7j7aLdwUhQmHWesEow==", + "license": "BlueOak-1.0.0", "dependencies": { - "minimatch": "^10.2.2", + "minimatch": "^11.0.2", "minipass": "^7.1.3", - "path-scurry": "^2.0.2" + "path-scurry": "^3.0.0" }, "engines": { "node": "18 || 20 || >=22" @@ -4743,11 +4772,13 @@ } }, "node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.3.tgz", + "integrity": "sha512-iny/40PEGkNJQ5/Pv6z3nm6FbRAby32GeZRjCUJk2WnNDbgPSTcdI+DDvLqsgBdD4cD5sMtMheqPPnehCCa8Nw==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.8" + "@isaacs/nonrecursive-glob-match": "^1.0.3", + "brace-expansion": "^5.0.12" }, "engines": { "node": "18 || 20 || >=22" @@ -4760,6 +4791,7 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -4809,9 +4841,10 @@ } }, "node_modules/node-forge": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", - "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.2.tgz", + "integrity": "sha512-rRtAR5XLJ8gCKd9du4qrKAsgCG1ObGP4gLeNJMa4vYSeq4bmBMOJk5IHkrviBV0q7uoAJlVd8HEQYQSVUb4FGQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", "engines": { "node": ">= 6.13.0" } @@ -5059,9 +5092,10 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-3.0.0.tgz", + "integrity": "sha512-1pTbQqH1wIwMi/eleQ5leFTTCkzFsPcgXNjwOA8YGGW/W4/kHT3dssE7Z6NH+zuqNLOzOB0uaD61yl7cAcVhgg==", + "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" @@ -5077,6 +5111,7 @@ "version": "11.5.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } diff --git a/mobile/package.json b/mobile/package.json index 3e93d13d9..7d3bb7530 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -28,6 +28,8 @@ "typescript": "~7.0.2" }, "overrides": { + "glob": "14.0.3", + "minimatch": "11.0.3", "zod": "4.6.7" } } diff --git a/mobile/scripts/security-backports.mjs b/mobile/scripts/security-backports.mjs index 284f92b81..5b2ed86fd 100644 --- a/mobile/scripts/security-backports.mjs +++ b/mobile/scripts/security-backports.mjs @@ -31,5 +31,5 @@ export function verifyBackports({ apply = false } = {}) { } if (process.argv[1] && existsSync(process.argv[1]) && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { verifyBackports({ apply: process.argv.includes('--apply') }); - console.log('Pinned braces/node-forge security backports verified.'); + console.log('Pinned braces/deepmerge security backports verified.'); } diff --git a/mobile/security/README.md b/mobile/security/README.md index 7e2eb52fa..4bfc7dbdf 100644 --- a/mobile/security/README.md +++ b/mobile/security/README.md @@ -6,13 +6,16 @@ Expo 58.0.17 / React Native 0.88.0 stay on the SDK-supported dependency matrix. | --- | --- | | [UUID buffer bounds, GHSA-w5hq-g745-h8pq](https://github.com/advisories/GHSA-w5hq-g745-h8pq) | Expo's compatible dependency tree now uses `xcode` 4.0.0, which removes UUID in favor of Node's `crypto.randomUUID()`. The obsolete override is removed; tests verify dependency removal and unique 24-character project identifiers. See [upstream release notes](https://github.com/apache/cordova-node-xcode/blob/4.0.0/RELEASENOTES.md). | | [braces recursion, GHSA-vfj7-8cjw-p6xm](https://github.com/advisories/GHSA-vfj7-8cjw-p6xm) | Upstream 3.0.3 has no published fix. Locally bound parser nesting and all three recursive AST walkers (compile, expand, stringify) to 256 levels. Iterative validation rejects cycles while ignoring normal `parent`/`prev` links. Tests reject deep balanced/unbalanced strings, deep ASTs and cycles, and preserve normal brace/glob results. | -| [node-forge DigestInfo validation, GHSA-86w9-cpqp-85rv](https://github.com/advisories/GHSA-86w9-cpqp-85rv) | Upstream 1.4.0 has no published fix. Reject extra DigestAlgorithm children and nonempty NULL parameters in RSA PKCS#1 v1.5 verification. Ephemeral-key regression tests reject signed malformed ASN.1, accept valid signatures with optional NULL and reject a wrong digest. | +| [node-forge DigestInfo validation, GHSA-86w9-cpqp-85rv](https://github.com/advisories/GHSA-86w9-cpqp-85rv), plus GHSA-5x38-2vff-x55p, GHSA-hqq3-7jhf-c5jx, GHSA-8hrf-8g6f-7xwp, GHSA-jc66-7q78-782g, GHSA-7wch-p9px-956x, GHSA-5cwg-6vw7-rj6p, GHSA-525v-9gp9-w22h, GHSA-6wvx-3r32-xmcg, GHSA-4487-j8r4-4g2f, GHSA-h6cr-wjv9-3cpp, GHSA-4642-5mcj-2rqw | Fixed upstream; the local backport is retired. Lockfile now resolves node-forge **1.4.2** (inside Expo's `^1.4.0`). 1.4.1 fixes the eleven advisories published for `<= 1.4.0`; 1.4.2 re-encodes the DigestInfo canonically and compares bytes (RFC 8017 8.2.2), which rejects extra DigestAlgorithm children and nonempty NULL parameters. The existing ephemeral-key regression test now runs unpatched against 1.4.2. Only `@expo/cli` (dev-server certificates) and `@expo/code-signing-certificates` use it; neither ships in the app. | +| [glob abort bypass, GHSA-3g3m-2fjr-6pw4](https://github.com/advisories/GHSA-3g3m-2fjr-6pw4) | Fixed upstream in glob 14.0.2; Expo SDK 58 still declares `glob ^13`, which has no fix. `package.json` `overrides` pins glob **14.0.3**. Expo's calls (`glob()`/`globSync()` with `cwd`, `ignore`, `absolute`, `dot`, `signal`) are unchanged in 14; none of Expo's `ignore` patterns start with `/`, the one semantic change. Tests run Expo CLI's own TypeScript-file glob and an aborted walk. Build-time only (`expo start`/`export`/`prebuild`, config plugins, fingerprint). Remove the override when Expo depends on glob 14.0.2 or later. | +| [minimatch pattern amplification, GHSA-g3hv-75wg-m249](https://github.com/advisories/GHSA-g3hv-75wg-m249) | Fixed upstream in minimatch 11.0.1; glob 13 and `@expo/fingerprint` declare `^10`. `package.json` `overrides` pins minimatch **11.0.3** (11.0.4 also drops Node 18 and was under a day old). 11 removes `makeRe()`, `Minimatch#regexp` and `AST`; no Expo package uses them. `@expo/fingerprint` uses only `new Minimatch()`, `.match()`, `.pattern` and `.negate`, which tests exercise with its default ignore patterns and a negation. Patterns come from Expo and the project, not users. Remove the override with glob's. | +| [deepmerge prototype poisoning, GHSA-gcqx-w48m-w2h4](https://github.com/advisories/GHSA-gcqx-w48m-w2h4) | Upstream 4.3.1 is the latest release and has no fix. Local backport in `dist/cjs.js` (the package's only entry point): `getKeys()` never returns an own `__proto__` key, so a `JSON.parse`d target or source cannot replace the merged object's prototype. Normal key, array and symbol merges are unchanged. Only `@expo/config` uses it, to merge the project's own app config at build time. Tests cover target, source, nested and `deepmerge.all` poisoning plus a normal config merge. | | [zod oversized collections, GHSA-mrhx-c7xc-m3w5](https://github.com/advisories/GHSA-mrhx-c7xc-m3w5) | Fixed upstream, no backport. Only `@expo/cli` (build-time dev-tools plugin schemas, not shipped in the app) depends on zod, declared as `^3.25.76`; no 3.x release has the fix. `package.json` `overrides` pins zod 4.6.7 (fix landed in 4.6.6). The CLI's schemas use only APIs zod 4 keeps; tests check the resolved version, run those CLI schemas under zod 4 and keep the oversized-collection regressions. Remove the override when Expo depends on zod 4.6.6 or later. | -These are local backports, not newly published upstream releases. Sources: [braces report](https://github.com/micromatch/braces/issues/70), [forge report](https://github.com/digitalbazaar/forge/issues/1149) and [upstream nested-length proposal](https://github.com/digitalbazaar/forge/pull/1152). The additional empty-NULL check prevents ignored bytes inside that primitive. This change does not redesign the cryptographic library. +These are local backports, not newly published upstream releases. Sources: [braces report](https://github.com/micromatch/braces/issues/70) and the [deepmerge advisory](https://github.com/advisories/GHSA-gcqx-w48m-w2h4). `backports.json` records exact upstream versions, before/after SHA-256 hashes and minimal replacement text. `postinstall` validates all sources before writing; repeated application is idempotent. Unknown versions or modified sources fail closed. Do not skip install scripts. `verify:security` also fails if any reviewed patched file is missing or altered, so bypassing postinstall cannot pass `npm run check`. -`audit:security` verifies source hashes, fetches the current npm advisory graph, and permits only the exact advisory URLs above and packages whose findings resolve to those verified backports. New advisories, unexplained findings and audit network failures fail the check. The full raw registry report and the disposition are retained in `dist/dependency-security.json` and in CI's unsigned export artifact. As of this update, raw npm audit still reports 4 high findings: `node-forge` 1.4.0 and its dependents. The verified patched-source gate reports zero unremediated advisories; it does not alter package versions or suppress npm's report. +`audit:security` verifies source hashes, fetches the current npm advisory graph, and permits only the exact advisory URLs above and packages whose findings resolve to those verified backports. New advisories, unexplained findings and audit network failures fail the check. The full raw registry report and the disposition are retained in `dist/dependency-security.json` and in CI's unsigned export artifact. As of this update, raw npm audit reports only the backported `braces` and `deepmerge` findings and their dependents. The verified patched-source gate reports zero unremediated advisories; it does not alter package versions or suppress npm's report. Before signing, run `npm ci`, `npm run check`, Expo Doctor and both normal Hermes exports. Recheck upstream releases: once a compatible fixed version exists, replace its backport with the supported dependency update, remove its manifest entries and advisory allowance together, then rerun the exploit regressions, Expo compatibility, native prebuild and both platform exports. Never add a new allowance without a reviewed source fix and meaningful regression coverage. diff --git a/mobile/security/backports.json b/mobile/security/backports.json index 6852bda7a..55c514fd2 100644 --- a/mobile/security/backports.json +++ b/mobile/security/backports.json @@ -1,7 +1,7 @@ { "advisories": { "braces": "https://github.com/advisories/GHSA-vfj7-8cjw-p6xm", - "node-forge": "https://github.com/advisories/GHSA-86w9-cpqp-85rv" + "deepmerge": "https://github.com/advisories/GHSA-gcqx-w48m-w2h4" }, "changes": [ { @@ -39,15 +39,6 @@ "find": "module.exports = (ast, options = {}) => {", "replace": "module.exports = (ast, options = {}) => {\n require('./security-depth')(ast);" }, - { - "package": "node-forge", - "version": "1.4.0", - "path": "lib/rsa.js", - "before_sha256": "fd4740238145ec26470eb3f06a627c72039538ce1307dbdce40521f94dfd0a50", - "after_sha256": "c30072d1621153aa8ff9544442225e32b5573b5ef1fcba2b0742e7b8bb472c2a", - "find": " obj.value.length !== 2) {", - "replace": " obj.value.length !== 2 ||\n obj.value[0].value.length < 1 ||\n obj.value[0].value.length > 2 ||\n ('parameters' in capture && capture.parameters !== '')) {" - }, { "package": "braces", "version": "3.0.3", @@ -56,6 +47,15 @@ "after_sha256": "c4e8f82890f9175ee71620f538463e40d249525efd3fe223bb9d531e7b37e289", "find": " while (index < length) {\n block = stack[stack.length - 1];", "replace": " while (index < length) {\n if (stack.length > 256) throw new SyntaxError('Brace pattern exceeds maximum nesting depth (256)');\n block = stack[stack.length - 1];" + }, + { + "package": "deepmerge", + "version": "4.3.1", + "path": "dist/cjs.js", + "before_sha256": "2e5160f84a1141bd927264afe4d15766f1e6cf07daef182b37ff53fea121de7d", + "after_sha256": "882879ee9a53cb91491b4956de7380963326ecfc0f2477e98c65706edad59206", + "find": "function getKeys(target) {\n\treturn Object.keys(target).concat(getEnumerableOwnPropertySymbols(target))\n}", + "replace": "// GHSA-gcqx-w48m-w2h4: an own \"__proto__\" key (for example from JSON.parse)\n// would replace the merged object's prototype when assigned. Never merge it.\nfunction getKeys(target) {\n\treturn Object.keys(target).concat(getEnumerableOwnPropertySymbols(target)).filter(function(key) {\n\t\treturn key !== '__proto__'\n\t})\n}" } ] } diff --git a/mobile/tests/dependency-security.test.mjs b/mobile/tests/dependency-security.test.mjs index 5e4fec58b..d89999ad8 100644 --- a/mobile/tests/dependency-security.test.mjs +++ b/mobile/tests/dependency-security.test.mjs @@ -63,6 +63,72 @@ test('RSA verification accepts valid signatures and rejects hidden DigestAlgorit } }); +const atLeast = (version, minimum) => { + const [a, b] = [version, minimum].map(v => v.split('.').map(Number)); + for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i]; + return true; +}; +const resolvedFrom = (consumer, name) => createRequire(consumer)(`${name}/package.json`).version; +const consumers = { + 'node-forge': [require.resolve('@expo/code-signing-certificates/package.json'), join(expoCliRoot, 'package.json')], + glob: [require.resolve('@expo/config/package.json'), require.resolve('@expo/fingerprint/package.json'), join(expoCliRoot, 'package.json'), + expoCliRequire.resolve('@expo/metro-config/package.json')], + minimatch: [require.resolve('@expo/fingerprint/package.json'), require.resolve('glob/package.json')] +}; + +test('Expo build tooling resolves fixed node-forge, glob and minimatch releases', () => { + // node-forge 1.4.1+ fixes GHSA-5x38/hqq3/8hrf/jc66/7wch/5cwg/525v/6wvx/4487/h6cr/4642; 1.4.2 adds the + // canonical DigestInfo check that supersedes the local GHSA-86w9 backport. glob 14.0.2 fixes + // GHSA-3g3m-2fjr-6pw4 and minimatch 11.0.1 fixes GHSA-g3hv-75wg-m249 (both forced by overrides). + for (const [name, minimum] of [['node-forge', '1.4.2'], ['glob', '14.0.2'], ['minimatch', '11.0.1']]) { + for (const consumer of consumers[name]) { + const version = resolvedFrom(consumer, name); + assert.ok(atLeast(version, minimum), `${consumer} resolves ${name} ${version}, below ${minimum}`); + } + } +}); + +test('deepmerge never lets an own __proto__ key replace the merged prototype', () => { + const deepmerge = require('deepmerge'); + const poisonedTarget = JSON.parse('{"__proto__":{"injected":true},"a":1,"nested":{"x":1}}'); + const poisonedSource = JSON.parse('{"__proto__":{"injected":true},"b":2,"nested":{"__proto__":{"injected":true},"y":2}}'); + for (const result of [deepmerge(poisonedTarget, {}), deepmerge({}, poisonedSource), deepmerge(poisonedTarget, poisonedSource), + deepmerge.all([poisonedTarget, poisonedSource, {}])]) { + assert.equal(Object.getPrototypeOf(result), Object.prototype); + assert.equal(result.injected, undefined); + assert.equal(Object.getPrototypeOf(result.nested), Object.prototype); + assert.equal(result.nested.injected, undefined); + } + assert.equal(({}).injected, undefined); + const symbol = Symbol('kept'); + assert.deepEqual(deepmerge({ name: 'app', plugins: ['a'], ios: { bundleIdentifier: 'x' } }, { plugins: ['b'], ios: { buildNumber: '2' } }), + { name: 'app', plugins: ['a', 'b'], ios: { bundleIdentifier: 'x', buildNumber: '2' } }); + assert.equal(deepmerge({}, { [symbol]: 1 })[symbol], 1); +}); + +test('Expo glob and fingerprint matching still behave under glob 14 and minimatch 11', async () => { + const root = mkdtempSync(join(tmpdir(), 'rupert-glob-')); + try { + for (const file of ['App.tsx', 'src/a.ts', 'src/types.d.ts', 'node_modules/x/index.ts', 'ios/Pods/p.ts', 'android/build/out.ts']) { + mkdirSync(dirname(join(root, file)), { recursive: true }); + writeFileSync(join(root, file), ''); + } + const { everyMatchAsync } = require(join(expoCliRoot, 'build/src/utils/glob.js')); + const found = await everyMatchAsync('**/*.@(ts|tsx)', { cwd: root, signal: AbortSignal.timeout(5000), + ignore: ['**/@(Carthage|Pods|node_modules)/**', '**/*.d.ts', '@(ios|android|web|web-build|dist)/**'] }); + assert.deepEqual([...found].sort(), ['App.tsx', 'src/a.ts']); + const { glob } = require('glob'); + const controller = new AbortController(); + controller.abort(); + await assert.rejects(glob('**/*', { cwd: root, signal: controller.signal })); + const { isIgnoredPath } = require('@expo/fingerprint/build/utils/Path.js'); + assert.equal(isIgnoredPath('android/build/out.ts', ['**/android/build/**/*']), true); + assert.equal(isIgnoredPath('src/a.ts', ['**/android/build/**/*']), false); + assert.equal(isIgnoredPath('ios/keep.txt', ['ios/**/*', '!ios/keep.txt']), false); + assert.equal(isIgnoredPath('.env', ['**/.env']), true); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + test('Expo CLI resolves the patched zod release, not the vulnerable 3.x line', () => { const version = createRequire(join(expoCliRoot, 'package.json'))('zod/package.json').version; assert.match(version, /^4\./); From 86623ba3b613c55e9c92c129ed96f2437a11a493 Mon Sep 17 00:00:00 2001 From: Sean Dolbec Date: Mon, 2 Nov 2026 18:54:33 -0500 Subject: [PATCH 2/5] Update loofah, nokogiri and rails-html-sanitizer for new advisories bundler-audit on main fails on advisories published today against loofah 2.25.2, nokogiri 1.19.4 and rails-html-sanitizer 1.7.1. Conservative update of only those three gems to loofah 2.25.3, nokogiri 1.19.7 and rails-html-sanitizer 1.7.2. Co-Authored-By: Claude Opus 5.5 --- web/Gemfile.lock | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/web/Gemfile.lock b/web/Gemfile.lock index 5b09a6d26..5c743f345 100644 --- a/web/Gemfile.lock +++ b/web/Gemfile.lock @@ -199,9 +199,9 @@ GEM language_server-protocol (3.17.0.6) lint_roller (1.1.0) logger (1.7.0) - loofah (2.25.2) + loofah (2.25.3) crass (~> 1.0.2) - nokogiri (>= 1.12.0) + nokogiri (>= 1.19.5) mail (2.9.1) logger mini_mime (>= 0.1.1) @@ -235,21 +235,21 @@ GEM net-smtp (0.5.2) net-protocol nio4r (2.7.5) - nokogiri (1.19.4-aarch64-linux-gnu) + nokogiri (1.19.7-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.4-aarch64-linux-musl) + nokogiri (1.19.7-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.19.4-arm-linux-gnu) + nokogiri (1.19.7-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.19.4-arm-linux-musl) + nokogiri (1.19.7-arm-linux-musl) racc (~> 1.4) - nokogiri (1.19.4-arm64-darwin) + nokogiri (1.19.7-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.4-x86_64-darwin) + nokogiri (1.19.7-x86_64-darwin) racc (~> 1.4) - nokogiri (1.19.4-x86_64-linux-gnu) + nokogiri (1.19.7-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.4-x86_64-linux-musl) + nokogiri (1.19.7-x86_64-linux-musl) racc (~> 1.4) nostr (0.7.0) bech32 (~> 1.4) @@ -319,8 +319,8 @@ GEM activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.7.1) - loofah (~> 2.25, >= 2.25.2) + rails-html-sanitizer (1.7.2) + loofah (~> 2.25, >= 2.25.3) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) railties (8.1.4) actionpack (= 8.1.4) @@ -635,7 +635,7 @@ CHECKSUMS language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 - loofah (2.25.2) sha256=2007f746959ac65552456e04b433e83deb22759ab38c838b4445c70e43425918 + loofah (2.25.3) sha256=545f14eef0cc2a7de80df560530d593232b7c07e515c5c4e37532d9cb5b0185c mail (2.9.1) sha256=06574eca475253d6c18145dd70af80d0eb970182d55053497c5f4d797ea160e8 marcel (1.2.1) sha256=1678e9360e32f9eafa917c80029e2f6d10b2715c66a4b87b6d0da9b9cd1f859f matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b @@ -653,14 +653,14 @@ CHECKSUMS net-protocol (0.4.0) sha256=f9f90c8c6f64794a45160bf422177a336db12809de3beeeaa26b88fcd2e8c853 net-smtp (0.5.2) sha256=35103cc04d6b2d94632f9928ede07483c24b70b9c0db1f231fea43909b678f3c nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 - nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f - nokogiri (1.19.4-aarch64-linux-musl) sha256=35c65b9ce72b3bb03207bdbe7067915019dc18c1b9b59139684bd6690fdd01af - nokogiri (1.19.4-arm-linux-gnu) sha256=a301313e38bb065d68239e79734bcd6f56fb6efaacebde29e9abf2a4735340ca - nokogiri (1.19.4-arm-linux-musl) sha256=588923c101bcfa78869734d247d25b598674323e7f22474fc468f6e5647311eb - nokogiri (1.19.4-arm64-darwin) sha256=a46db9853286e6597b36ebc6953817d15acf3a299583eb3f89fdc6f91dd63527 - nokogiri (1.19.4-x86_64-darwin) sha256=7fd17057d3e1f00e9954a74b3cd76595d3d4a5ef233b7ed9599047c204f70551 - nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a - nokogiri (1.19.4-x86_64-linux-musl) sha256=17dfb7c1fa194ae02fbf7c51a7afc8d278045ab3fdacfd86f91d02d7b274470b + nokogiri (1.19.7-aarch64-linux-gnu) sha256=8f5e15d299602456406e1f03a9f3456cceb33a0722811e818d2ee2214eb4c84a + nokogiri (1.19.7-aarch64-linux-musl) sha256=efe0c3602e6e54521bc21c681930ce5568e2d11203de17400710b2b3ca2e4cb4 + nokogiri (1.19.7-arm-linux-gnu) sha256=7fd1907125691aa89e838cc91161761c758b4e17bb32f0dd9b5515a82cc759af + nokogiri (1.19.7-arm-linux-musl) sha256=28d0ff0c87b6bb2e56580323a6fb049d5d4a02b94f0958fdd47e9ac146991c77 + nokogiri (1.19.7-arm64-darwin) sha256=f27d7f25f4873fbc3aa35a2cc2fb680c98555fec1925348da439d0869d541142 + nokogiri (1.19.7-x86_64-darwin) sha256=508e81e8c5f84aaa240dce981bf1c43faae60808726f0ce7024ee92effc5cafe + nokogiri (1.19.7-x86_64-linux-gnu) sha256=25865ccb462f714e712fdf8906fde07849cb6704a808e3d629d75baa5af5dd3b + nokogiri (1.19.7-x86_64-linux-musl) sha256=464d1f3dfb0d53cf54c0c6a7e23e8e91cf68ec3cafc2b27dc4a7682f2d9bf546 nostr (0.7.0) sha256=2971efb01821857203d5e32569b3ab1bfa0f4904da4b9c1a7822112e904ccd90 oj (3.17.7) sha256=7a21d3ac8e544a850f1cdaf711b0c8144ba587d14d3e4917c0855fd7b0483db7 openssl (4.0.2) sha256=1037ad2868ae58df9ad917891c0c0f9815a1172f6846d4bcdd508e4c2ee747c2 @@ -688,7 +688,7 @@ CHECKSUMS rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 rails (8.1.4) sha256=5f3ecdf9a2a3b79b2e31b8fee3b64a00f7284d76e5631f87d0e58e665911a33b rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d - rails-html-sanitizer (1.7.1) sha256=e797a7c9b01e567307e317c576b49ab4168017e63eea4dba9ce3cb587e2f22c2 + rails-html-sanitizer (1.7.2) sha256=a21ef45b7a6639fc8e2282473c547514cb86ae271616162a946bf89148fa2424 railties (8.1.4) sha256=b5e7a8e8984e33114d68af62227971960aae1e6c02bd7c9d826ffad97fff583c rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 From 067deb59cd870887b6092c62958e635ddc0cf677 Mon Sep 17 00:00:00 2001 From: Sean Dolbec Date: Mon, 2 Nov 2026 18:55:04 -0500 Subject: [PATCH 3/5] Make the host vitals test portable to macOS The test read /proc/loadavg and /proc/meminfo directly, so it failed on a Mac. HostVitals now reads /proc through one small method that the test stubs with fixed contents, checking the exact CPU and memory percentages instead of only their types. Disk and database size still come from the real host. The real /proc read still runs where /proc exists (Linux CI). A new test pins that an unreadable reading is nil. Co-Authored-By: Claude Opus 5.5 --- web/app/services/host_vitals.rb | 9 +++++-- web/test/services/host_vitals_test.rb | 35 ++++++++++++++++++++++++--- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/web/app/services/host_vitals.rb b/web/app/services/host_vitals.rb index f24e2aa1d..2dbd2ca47 100644 --- a/web/app/services/host_vitals.rb +++ b/web/app/services/host_vitals.rb @@ -68,14 +68,14 @@ def cgroup_cpu_usec # Without a cgroup (a laptop, say): the one-minute load average as a share of the cores. def load_percent - load1 = File.read('/proc/loadavg').split.first.to_f + load1 = read_host_file('/proc/loadavg').split.first.to_f [(load1 / Etc.nprocessors * 100).round, 100].min rescue SystemCallError, ZeroDivisionError nil end def memory_percent - info = File.read('/proc/meminfo').lines.to_h do |line| + info = read_host_file('/proc/meminfo').lines.to_h do |line| key, value = line.split(':') [key, value.to_i] end @@ -86,6 +86,11 @@ def memory_percent nil end + # Linux-only /proc files; tests stub this so the parsing is checked on any host. + def read_host_file(path) + File.read(path) + end + # Use of the filesystem holding the database, as df reports it. def disk_percent output = IO.popen(['df', '-Pk', database_path.dirname.to_s], err: File::NULL, &:read) diff --git a/web/test/services/host_vitals_test.rb b/web/test/services/host_vitals_test.rb index 34d2db15a..6b96338d0 100644 --- a/web/test/services/host_vitals_test.rb +++ b/web/test/services/host_vitals_test.rb @@ -3,12 +3,41 @@ require 'test_helper' class HostVitalsTest < ActiveSupport::TestCase - test 'reads load, memory, disk and database size from this host' do + # /proc is Linux-only, so these readings are stubbed and the parsing is checked on any host (CI or a Mac). + PROC_FILES = { + '/proc/loadavg' => "1.50 0.80 0.40 2/345 6789\n", + '/proc/meminfo' => "MemTotal: 16000000 kB\nMemFree: 2000000 kB\nMemAvailable: 4000000 kB\n" + }.freeze + + test 'reads load, memory, disk and database size' do + HostVitals.stub(:cgroup_cpu_usec, nil) do + HostVitals.stub(:read_host_file, ->(path) { PROC_FILES.fetch(path) }) do + Etc.stub(:nprocessors, 4) do + reading = HostVitals.read + assert_equal 38, reading.cpu, 'one-minute load 1.5 on 4 cores' + assert_equal 75, reading.memory, '12 of 16 GB in use' + assert reading.disk.between?(0, 100) + assert reading.db_bytes.positive? + end + end + end + end + + test 'a reading this host cannot take is nil, never 0' do + HostVitals.stub(:cgroup_cpu_usec, nil) do + HostVitals.stub(:read_host_file, ->(path) { raise Errno::ENOENT, path }) do + reading = HostVitals.read + assert_nil reading.cpu + assert_nil reading.memory + end + end + end + + test 'reads CPU and memory from a real Linux host' do + skip 'needs Linux /proc' unless File.readable?('/proc/meminfo') reading = HostVitals.read assert_kind_of Integer, reading.cpu assert reading.memory.between?(0, 100) - assert reading.disk.between?(0, 100) - assert reading.db_bytes.positive? end test 'CPU is this container\'s own CPU time, not the host load average' do From 50ad0d16181984182d79aad2f91496de2b50c162 Mon Sep 17 00:00:00 2001 From: Sean Dolbec Date: Mon, 2 Nov 2026 18:55:49 -0500 Subject: [PATCH 4/5] Read the instance label as UTF-8 under any locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under a C/POSIX locale (a shell without LANG, as on some Macs) Ruby returns ENV values as binary, so a label such as "SER8 ยท Incus" did not equal the UTF-8 string and the platform overview test failed. The label is now read as UTF-8, and a value that is not valid UTF-8 shows as "Not configured" instead of raising. Co-Authored-By: Claude Opus 5.5 --- web/app/services/instance_overview.rb | 6 +++++- web/test/integration/platform_test.rb | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/web/app/services/instance_overview.rb b/web/app/services/instance_overview.rb index 2854566f4..5f7a97ecb 100644 --- a/web/app/services/instance_overview.rb +++ b/web/app/services/instance_overview.rb @@ -94,7 +94,11 @@ def instance end def deployment_label - value = ENV['RUPERT_INSTANCE_LABEL'].to_s.strip + # Read as UTF-8 whatever the server's locale; under a C/POSIX locale ENV values arrive as binary. + value = ENV['RUPERT_INSTANCE_LABEL'].to_s.dup.force_encoding(Encoding::UTF_8) + return 'Not configured' unless value.valid_encoding? + + value = value.strip value.present? && value.length <= 80 && !value.match?(/[\x00-\x1f\x7f]/) ? value : 'Not configured' end diff --git a/web/test/integration/platform_test.rb b/web/test/integration/platform_test.rb index 06d1b45c7..716b18815 100644 --- a/web/test/integration/platform_test.rb +++ b/web/test/integration/platform_test.rb @@ -206,6 +206,8 @@ class PlatformTest < ActionDispatch::IntegrationTest %w[PRIVATE_SYNC_DETAILS PRIVATE_BACKUP_FILENAME PRIVATE_BACKUP_LINK].each { |value| refute_includes response.body, value } ENV['RUPERT_INSTANCE_LABEL'] = "Invalid\nlabel" assert_equal 'Not configured', InstanceOverview.call(actor: @owner)[:instance][:label] + ENV['RUPERT_INSTANCE_LABEL'] = "Not UTF-8 \xFF".b + assert_equal 'Not configured', InstanceOverview.call(actor: @owner)[:instance][:label] ensure ENV['RUPERT_INSTANCE_LABEL'] = old_label end From 9a7edfbee55dda939ec969a1a0f8ed042b9ec7b2 Mon Sep 17 00:00:00 2001 From: Sean Dolbec Date: Mon, 2 Nov 2026 19:04:21 -0500 Subject: [PATCH 5/5] Give each card reconciliation test its own job lock file PosPaymentReconciliationJob takes one machine-wide flock and skips its sweep when the lock is held. Parallel test workers shared that file, so when two PosCardPaymentTest cases ran the job at the same moment one silently did nothing. That intermittently failed the overlap test (line 72) and the rotation test (line 56) on main and on several PRs. The lock path is now one small method, and the tests point it at a temporary file per test. Holding the real lock from another process reproduces both failures on the old code, and they pass on the new. Production still uses tmp/locks/pos-payment-reconciliation.lock. Co-Authored-By: Claude Opus 5.5 --- web/app/jobs/pos_payment_reconciliation_job.rb | 9 ++++++--- web/test/services/pos_card_payment_test.rb | 8 +++++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/web/app/jobs/pos_payment_reconciliation_job.rb b/web/app/jobs/pos_payment_reconciliation_job.rb index c821d542e..2cd4522dd 100644 --- a/web/app/jobs/pos_payment_reconciliation_job.rb +++ b/web/app/jobs/pos_payment_reconciliation_job.rb @@ -7,9 +7,8 @@ def perform # SQLite deployments share one filesystem. An advisory process lock keeps # a slow gateway request from overlapping a later scheduled sweep; it is # released automatically if the worker exits. - directory = Rails.root.join('tmp/locks') - FileUtils.mkdir_p(directory) - File.open(directory.join('pos-payment-reconciliation.lock'), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock| + FileUtils.mkdir_p(lock_path.dirname) + File.open(lock_path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock| return unless lock.flock(File::LOCK_EX | File::LOCK_NB) reconcile_batch @@ -20,6 +19,10 @@ def perform private + def lock_path + Rails.root.join('tmp/locks/pos-payment-reconciliation.lock') + end + def reconcile_batch PosPaymentAttempt.unscoped.joins(:tenant).where(tenants: { status: 'active' }) .where(status: %w[submitting unknown]).where('submitted_at < ?', 1.minute.ago) diff --git a/web/test/services/pos_card_payment_test.rb b/web/test/services/pos_card_payment_test.rb index 68296ec0f..9308b506b 100644 --- a/web/test/services/pos_card_payment_test.rb +++ b/web/test/services/pos_card_payment_test.rb @@ -3,6 +3,9 @@ require 'test_helper' class PosCardPaymentTest < ActiveSupport::TestCase setup do + # Parallel test workers would otherwise share the job's one lock file, and a job that finds it held skips its sweep. + @lock_dir = Dir.mktmpdir('pos-reconciliation-lock') + PosPaymentReconciliationJob.any_instance.stubs(:lock_path).returns(Pathname(@lock_dir).join('reconciliation.lock')) Current.tenant = tenants(:default_tenant) @location = Location.create!(source: 'ctrax', externalId: 'card-fixture/1', name: 'Card fixture') @user = users(:regular_user) @@ -40,7 +43,10 @@ class PosCardPaymentTest < ActiveSupport::TestCase { status: 200, headers: { 'Content-Type' => 'application/json' }, body: data.merge(messages: { resultCode: 'Ok' }).to_json } end end - teardown { Current.reset } + teardown do + Current.reset + FileUtils.rm_rf(@lock_dir) + end test 'reconciliation rotates past unresolved attempts without sending charges' do 51.times do |i|