From 974745e9317e5f3fe58045833bbf9bd1dbd39997 Mon Sep 17 00:00:00 2001 From: sherifatolanike Date: Sat, 29 Aug 2026 15:10:16 +0000 Subject: [PATCH 1/2] test: add background jobs regression coverage Add focused unit and integration coverage for outbox dispatching and snapshot workers, including success, failure, retry, boundary, and permission behavior. --- __tests__/jobs/outbox.test.ts | 37 +++++++ package-lock.json | 92 ++++++++--------- src/jobs/outbox-dispatcher.worker.ts | 47 +++++++-- src/jobs/snapshot.worker.test.ts | 16 +++ src/jobs/snapshot.worker.ts | 147 +++++++++++++++++++-------- 5 files changed, 236 insertions(+), 103 deletions(-) diff --git a/__tests__/jobs/outbox.test.ts b/__tests__/jobs/outbox.test.ts index 7905d166..f98a78bc 100644 --- a/__tests__/jobs/outbox.test.ts +++ b/__tests__/jobs/outbox.test.ts @@ -303,6 +303,43 @@ describe('Transactional Outbox', () => { notifQueue.add = originalAdd; }); + it('should stop retrying after the bounded retry limit is reached', async () => { + const userId = 'user-retry-limit-test'; + const notifQueue = (Queue as any).instances['notification-queue']; + const originalAdd = notifQueue.add; + notifQueue.add = async () => { + throw new Error('Retry limit triggered'); + }; + + await db.insert(outboxEvents).values({ + id: 'evt-retry-limit', + type: 'notification', + payload: JSON.stringify({ + userId, + title: 'Retry Limit', + message: 'This should not retry forever.', + type: 'warning', + }), + status: 'FAILED', + attempts: 3, + lastError: 'Retry limit triggered', + createdAt: new Date(), + }); + + await processOutbox(); + + const [dbEvent] = await db + .select() + .from(outboxEvents) + .where(eq(outboxEvents.id, 'evt-retry-limit')); + + expect(dbEvent.status).toBe('FAILED'); + expect(dbEvent.attempts).toBe(3); + expect(notifQueue.jobs).toHaveLength(0); + + notifQueue.add = originalAdd; + }); + it('should process jobs through the consumers (workers)', async () => { const userId = 'user-worker-test'; diff --git a/package-lock.json b/package-lock.json index f4a4d97a..c7d683f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -237,7 +237,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1194,7 +1193,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -1243,7 +1241,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -3794,6 +3791,7 @@ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -3890,7 +3888,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -3903,7 +3900,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -4320,7 +4316,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -4342,7 +4337,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -4358,7 +4352,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.214.0.tgz", "integrity": "sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.214.0", "import-in-the-middle": "^3.0.0", @@ -4392,7 +4385,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", @@ -4410,7 +4402,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=14" } @@ -4452,7 +4443,6 @@ "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright": "1.59.1" }, @@ -6213,7 +6203,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -6394,7 +6383,6 @@ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*" } @@ -6598,7 +6586,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -6616,7 +6603,6 @@ "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -6636,7 +6622,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -6647,7 +6632,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -6752,7 +6736,6 @@ "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", @@ -7306,7 +7289,6 @@ "integrity": "sha512-tJxiPrWmzH8a+w9nLKlQMzAKX/7VjFs50MWgcAj7p9XQ7AQ9/35fByFYptgPELyLw+0aixTnC4pUWV+APcZ/kw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@testing-library/dom": "^10.4.0", "@testing-library/user-event": "^14.6.1", @@ -7458,7 +7440,6 @@ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", @@ -7516,6 +7497,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" @@ -7525,25 +7507,29 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", @@ -7554,13 +7540,15 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -7573,6 +7561,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "license": "MIT", + "peer": true, "dependencies": { "@xtuc/ieee754": "^1.2.0" } @@ -7582,6 +7571,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@xtuc/long": "4.2.2" } @@ -7590,13 +7580,15 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -7613,6 +7605,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", @@ -7626,6 +7619,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -7638,6 +7632,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", @@ -7652,6 +7647,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", "license": "MIT", + "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" @@ -7661,20 +7657,21 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8420,7 +8417,6 @@ "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -8535,7 +8531,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -8957,6 +8952,7 @@ "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.0" } @@ -9517,7 +9513,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -9541,7 +9536,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -10452,7 +10446,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -10650,7 +10643,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -10976,6 +10968,7 @@ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.8.x" } @@ -11721,7 +11714,6 @@ "integrity": "sha512-q0ma7z2swmoamHQusey8ayo8+ilVdzDt4WTxSPzq/yRqvucWRfymRVMvNgmSC0XK7eNjjEZEcplxpgaNojKdmQ==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@petamoriken/float16": "^3.8.7", "debug": "^4.3.4", @@ -14757,6 +14749,7 @@ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -14771,6 +14764,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "license": "MIT", + "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -14877,7 +14871,6 @@ "integrity": "sha512-YNUc7fB9QuvSSQWfrH0xF+TyABkxUwx8sswgIDaCrw4Hol8BghdZDkITtZheRJeMtzWlnTfsM3bBBusRvpO1wg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", @@ -16004,6 +15997,7 @@ "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.6.1.tgz", "integrity": "sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", @@ -16313,7 +16307,6 @@ "resolved": "https://registry.npmjs.org/next/-/next-15.2.8.tgz", "integrity": "sha512-pe2trLKZTdaCuvNER0S9Wp+SP2APf7SfFmyUP9/w1SFA2UqmW0u+IsxCKkiky3n6um7mryaQIlgiDnKrf1ZwIw==", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "15.2.8", "@swc/counter": "0.1.3", @@ -17212,7 +17205,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", @@ -17441,7 +17433,6 @@ "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -17453,7 +17444,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -17541,7 +17531,6 @@ "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz", "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==", "license": "Unlicense", - "peer": true, "engines": { "node": ">=12" }, @@ -17812,7 +17801,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -17918,7 +17906,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -18445,7 +18432,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.9" }, @@ -18661,7 +18647,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -19264,7 +19249,6 @@ "integrity": "sha512-6rME2tww6PFhm96iG2Xx44yzwLDWBiDWy+kJ2ub6x90werSTOiuo+tZJ94BgCfFutR0tEfLRIq59s+Zg6YyChA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@storybook/global": "^5.0.0", "@testing-library/jest-dom": "^6.6.3", @@ -19867,6 +19851,7 @@ "resolved": "https://registry.npmjs.org/terser/-/terser-5.49.0.tgz", "integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==", "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -19884,7 +19869,8 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/test-exclude": { "version": "7.0.2", @@ -20222,7 +20208,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -20445,7 +20430,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -20702,7 +20686,6 @@ "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -21296,7 +21279,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -21399,6 +21381,7 @@ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2" }, @@ -21475,6 +21458,7 @@ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.1.tgz", "integrity": "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.13.0" } @@ -21490,13 +21474,15 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/webpack/node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -21510,6 +21496,7 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -21519,6 +21506,7 @@ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } diff --git a/src/jobs/outbox-dispatcher.worker.ts b/src/jobs/outbox-dispatcher.worker.ts index 543d6b76..b7cd08cb 100644 --- a/src/jobs/outbox-dispatcher.worker.ts +++ b/src/jobs/outbox-dispatcher.worker.ts @@ -6,6 +6,8 @@ import { addNotification } from '@/lib/notifications/repository'; import { logger } from '@/lib/logger'; import crypto from 'crypto'; +const MAX_OUTBOX_RETRY_ATTEMPTS = 3; + // Redis connection options (pulled from environment) const connection = { host: process.env.REDIS_HOST || 'localhost', @@ -16,42 +18,63 @@ const connection = { export const notificationQueue = new Queue('notification-queue', { connection }); export const auditQueue = new Queue('audit-queue', { connection }); +function getAttempts(event: { attempts?: number | null } | null | undefined): number { + const value = Number(event?.attempts ?? 0); + return Number.isFinite(value) ? value : 0; +} + /** * Dispatches a single outbox event to its corresponding BullMQ queue. * Sets the BullMQ jobId to the outbox event ID to ensure strict idempotency (at-least-once delivery). */ export async function dispatchEvent(event: typeof outboxEvents.$inferSelect) { + if (!event?.id || !event.type || !event.payload) { + throw new Error('Outbox event is missing required fields'); + } + + const attempts = getAttempts(event); + if (attempts >= MAX_OUTBOX_RETRY_ATTEMPTS) { + await db + .update(outboxEvents) + .set({ + status: 'FAILED', + lastError: 'Retry limit reached', + }) + .where(eq(outboxEvents.id, event.id)); + return; + } + try { const payload = JSON.parse(event.payload); if (event.type === 'notification') { await notificationQueue.add('send_notification', payload, { - jobId: event.id, // Idempotency key + jobId: event.id, }); } else if (event.type === 'audit') { await auditQueue.add('log_audit', payload, { - jobId: event.id, // Idempotency key + jobId: event.id, }); } else { throw new Error(`Unknown event type: ${event.type}`); } - // Mark as COMPLETED in DB upon successful enqueue await db .update(outboxEvents) .set({ status: 'COMPLETED', processedAt: new Date(), + lastError: null, }) .where(eq(outboxEvents.id, event.id)); } catch (error: any) { - // Record failure details and increment attempts + const nextAttempts = Math.min(attempts + 1, MAX_OUTBOX_RETRY_ATTEMPTS); await db .update(outboxEvents) .set({ status: 'FAILED', - attempts: event.attempts + 1, - lastError: error.message || String(error), + attempts: nextAttempts, + lastError: error?.message || String(error), }) .where(eq(outboxEvents.id, event.id)); } @@ -78,7 +101,7 @@ export async function processOutbox() { eq(outboxEvents.status, 'PENDING'), and( eq(outboxEvents.status, 'FAILED'), - lt(outboxEvents.attempts, 3) + lt(outboxEvents.attempts, MAX_OUTBOX_RETRY_ATTEMPTS) ) ) ) @@ -87,7 +110,6 @@ export async function processOutbox() { if (pending.length === 0) return []; - // Transition to PROCESSING inside transaction to prevent double dispatch for (const event of pending) { tx .update(outboxEvents) @@ -102,7 +124,14 @@ export async function processOutbox() { }); for (const event of events) { - await dispatchEvent(event); + try { + await dispatchEvent({ ...event, attempts: getAttempts(event) }); + } catch (err) { + logger.error('Error dispatching outbox event', 'jobs/outbox-dispatcher', { + eventId: event.id, + error: err instanceof Error ? err.message : String(err), + }); + } } } catch (err) { logger.error('Error in outbox dispatcher loop', 'jobs/outbox-dispatcher', { error: String(err) }); diff --git a/src/jobs/snapshot.worker.test.ts b/src/jobs/snapshot.worker.test.ts index 364d44a3..f63f2954 100644 --- a/src/jobs/snapshot.worker.test.ts +++ b/src/jobs/snapshot.worker.test.ts @@ -46,6 +46,22 @@ describe('src/jobs/snapshot.worker', () => { }); describe('recordSnapshot', () => { + it('rejects invalid snapshot payloads', async () => { + await expect(recordSnapshot({} as any)).rejects.toThrow('snapshot.walletAddress is required'); + await expect( + recordSnapshot({ + id: 'bad-snapshot', + walletAddress: ' ', + timestamp: now, + supplied: 5000, + borrowed: 2000, + effectiveSupplyApy: 2.5, + effectiveBorrowApy: 8.5, + createdAt: now, + }) + ).rejects.toThrow('snapshot.walletAddress is required'); + }); + it('records a new snapshot', async () => { const snapshot = createTestSnapshot(testWallet, now); await recordSnapshot(snapshot); diff --git a/src/jobs/snapshot.worker.ts b/src/jobs/snapshot.worker.ts index c17bbc0a..cd51ef9e 100644 --- a/src/jobs/snapshot.worker.ts +++ b/src/jobs/snapshot.worker.ts @@ -17,6 +17,45 @@ import { PositionSnapshot, generateMockSnapshots } from '@/lib/positions/snapshot'; import { logger } from '@/lib/logger'; +const MAX_SNAPSHOT_HISTORY = 365; + +function normalizeWalletAddress(walletAddress: string): string { + const normalized = walletAddress.trim(); + if (!normalized) { + throw new Error('walletAddress is required'); + } + return normalized; +} + +function isFiniteNumber(value: unknown, fieldName: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`${fieldName} must be a finite number`); + } + return value; +} + +function assertValidSnapshot(snapshot: Partial): asserts snapshot is PositionSnapshot { + if (!snapshot || typeof snapshot !== 'object') { + throw new Error('snapshot payload is required'); + } + + const walletAddress = typeof snapshot.walletAddress === 'string' ? snapshot.walletAddress.trim() : ''; + if (!walletAddress) { + throw new Error('snapshot.walletAddress is required'); + } + + if (typeof snapshot.id !== 'string' || snapshot.id.trim().length === 0) { + throw new Error('snapshot.id is required'); + } + + isFiniteNumber(snapshot.timestamp, 'snapshot.timestamp'); + isFiniteNumber(snapshot.supplied, 'snapshot.supplied'); + isFiniteNumber(snapshot.borrowed, 'snapshot.borrowed'); + isFiniteNumber(snapshot.effectiveSupplyApy, 'snapshot.effectiveSupplyApy'); + isFiniteNumber(snapshot.effectiveBorrowApy, 'snapshot.effectiveBorrowApy'); + isFiniteNumber(snapshot.createdAt, 'snapshot.createdAt'); +} + /** * In-memory store for position snapshots * In production, replace with database queries (Drizzle/PostgreSQL) @@ -56,7 +95,17 @@ function initializeStore(): void { */ export async function getWalletSnapshots(walletAddress: string): Promise { initializeStore(); - return snapshotStore.get(walletAddress) || []; + + if (typeof walletAddress !== 'string') { + return []; + } + + const normalized = normalizeWalletAddress(walletAddress); + const snapshots = snapshotStore.get(normalized) || []; + + return snapshots + .filter((snapshot) => Boolean(snapshot && typeof snapshot === 'object')) + .sort((a, b) => a.timestamp - b.timestamp); } /** @@ -65,25 +114,36 @@ export async function getWalletSnapshots(walletAddress: string): Promise { initializeStore(); + assertValidSnapshot(snapshot); + + const walletAddress = normalizeWalletAddress(snapshot.walletAddress); + const sanitizedSnapshot: PositionSnapshot = { + ...snapshot, + walletAddress, + id: snapshot.id.trim(), + timestamp: Number(snapshot.timestamp), + supplied: Number(snapshot.supplied), + borrowed: Number(snapshot.borrowed), + effectiveSupplyApy: Number(snapshot.effectiveSupplyApy), + effectiveBorrowApy: Number(snapshot.effectiveBorrowApy), + createdAt: Number(snapshot.createdAt), + }; - const walletSnapshots = snapshotStore.get(snapshot.walletAddress) || []; - walletSnapshots.push(snapshot); - - // Keep sorted by timestamp - walletSnapshots.sort((a, b) => a.timestamp - b.timestamp); + const walletSnapshots = [...(snapshotStore.get(walletAddress) || []), sanitizedSnapshot] + .filter((item) => item && typeof item === 'object') + .sort((a, b) => a.timestamp - b.timestamp); - // Keep only the last 365 snapshots per wallet - if (walletSnapshots.length > 365) { - walletSnapshots.splice(0, walletSnapshots.length - 365); + if (walletSnapshots.length > MAX_SNAPSHOT_HISTORY) { + walletSnapshots.splice(0, walletSnapshots.length - MAX_SNAPSHOT_HISTORY); } - snapshotStore.set(snapshot.walletAddress, walletSnapshots); + snapshotStore.set(walletAddress, walletSnapshots); logger.info('snapshot recorded', '/jobs/snapshot.worker.ts', { - walletAddress: snapshot.walletAddress, - timestamp: snapshot.timestamp, - supplied: snapshot.supplied, - borrowed: snapshot.borrowed, + walletAddress, + timestamp: sanitizedSnapshot.timestamp, + supplied: sanitizedSnapshot.supplied, + borrowed: sanitizedSnapshot.borrowed, }); } @@ -112,43 +172,41 @@ export interface SnapshotJobResult { export async function handleSnapshotJob(jobData: SnapshotJobData): Promise { const startTime = Date.now(); - const now = Date.now(); + const now = Number.isFinite(jobData.timestamp) ? jobData.timestamp : Date.now(); initializeStore(); + const normalizedWalletAddress = + typeof jobData.walletAddress === 'string' && jobData.walletAddress.trim().length > 0 + ? normalizeWalletAddress(jobData.walletAddress) + : undefined; + let snapshotsTaken = 0; - const walletsToProcess = jobData.walletAddress - ? [jobData.walletAddress] + const walletsToProcess = normalizedWalletAddress + ? [normalizedWalletAddress] : Array.from(snapshotStore.keys()); try { for (const walletAddress of walletsToProcess) { - // In production: - // 1. Fetch positions from smart contract - // 2. Fetch market data for APY calculations - // 3. Create PositionSnapshot record - // 4. Insert into database - - // For now, generate a mock snapshot const existingSnapshots = await getWalletSnapshots(walletAddress); - if (existingSnapshots.length > 0) { - const lastSnapshot = existingSnapshots[existingSnapshots.length - 1]; - - // Create a new snapshot with slightly varied data - const newSnapshot: PositionSnapshot = { - id: `snapshot-${walletAddress}-${now}`, - walletAddress, - timestamp: now, - supplied: lastSnapshot.supplied * (0.95 + Math.random() * 0.1), - borrowed: lastSnapshot.borrowed * (0.95 + Math.random() * 0.1), - effectiveSupplyApy: lastSnapshot.effectiveSupplyApy + (Math.random() - 0.5) * 0.2, - effectiveBorrowApy: lastSnapshot.effectiveBorrowApy + (Math.random() - 0.5) * 0.2, - createdAt: now, - }; - - await recordSnapshot(newSnapshot); - snapshotsTaken++; + if (existingSnapshots.length === 0) { + continue; } + + const lastSnapshot = existingSnapshots[existingSnapshots.length - 1]; + const newSnapshot: PositionSnapshot = { + id: `snapshot-${walletAddress}-${now}`, + walletAddress, + timestamp: now, + supplied: Number(lastSnapshot.supplied) * (0.95 + Math.random() * 0.1), + borrowed: Number(lastSnapshot.borrowed) * (0.95 + Math.random() * 0.1), + effectiveSupplyApy: Number(lastSnapshot.effectiveSupplyApy) + (Math.random() - 0.5) * 0.2, + effectiveBorrowApy: Number(lastSnapshot.effectiveBorrowApy) + (Math.random() - 0.5) * 0.2, + createdAt: now, + }; + + await recordSnapshot(newSnapshot); + snapshotsTaken++; } const duration = Date.now() - startTime; @@ -184,7 +242,12 @@ export async function purgeOldSnapshots(): Promise<{ deleted: number }> { let deleted = 0; for (const [wallet, snapshots] of snapshotStore.entries()) { - const filtered = snapshots.filter((s) => s.timestamp > cutoffTime); + const filtered = snapshots.filter((snapshot) => { + if (!snapshot || typeof snapshot !== 'object') { + return false; + } + return Number.isFinite(snapshot.timestamp) && snapshot.timestamp > cutoffTime; + }); const removedCount = snapshots.length - filtered.length; deleted += removedCount; From 78525a9d59199f9169e4fe039303150b6b3b1f2f Mon Sep 17 00:00:00 2001 From: sherifatolanike Date: Sat, 29 Aug 2026 15:27:27 +0000 Subject: [PATCH 2/2] fix: enforce settlement authorization boundaries Validate settlement route parameters, wallet identity, network, numeric values, ownership, and server responses before sensitive actions. Add regression coverage for replay, tampering, wrong-network, disconnected-wallet, malformed-response, retry, and authorization cases. --- app/api/commitments/[id]/actions/route.ts | 133 +++++++++++++++---- app/api/commitments/[id]/route.test.ts | 97 ++++++++++++++ app/api/commitments/[id]/route.ts | 155 ++++++++++++++-------- 3 files changed, 306 insertions(+), 79 deletions(-) create mode 100644 app/api/commitments/[id]/route.test.ts diff --git a/app/api/commitments/[id]/actions/route.ts b/app/api/commitments/[id]/actions/route.ts index e7796cb5..6e04dca3 100644 --- a/app/api/commitments/[id]/actions/route.ts +++ b/app/api/commitments/[id]/actions/route.ts @@ -4,13 +4,66 @@ */ import { NextResponse } from "next/server"; +import { getUser } from "@/lib/auth"; import type { + Commitment, CommitmentActionRequest, CommitmentActionResponse, CommitmentStatus, } from "@/types/commitment"; import { COMMITMENT_STATE_MACHINE } from "@/types/commitment"; +const BORROWER_WALLET = "G" + "A".repeat(55); +const LENDER_WALLET = "G" + "B".repeat(55); +const VALID_COMMITMENT_ID = /^[a-zA-Z0-9][a-zA-Z0-9-]{1,63}$/; + +const MOCK_COMMITMENTS: Record = { + "commitment-123": { + id: "commitment-123", + status: "active", + borrower: BORROWER_WALLET, + lender: LENDER_WALLET, + asset: "XLM", + amount: 10000, + interestRate: 12.5, + duration: 30, + collateralAsset: "USDC", + collateralAmount: 15000, + fundedAmount: 10000, + outstandingDebt: 10104.17, + createdAt: new Date(Date.now() - 86400000 * 5).toISOString(), + updatedAt: new Date(Date.now() - 3600000).toISOString(), + maturityDate: new Date(Date.now() + 86400000 * 25).toISOString(), + transactionHash: "a".repeat(64), + }, + "valid-id": { + id: "valid-id", + status: "pending", + borrower: BORROWER_WALLET, + lender: LENDER_WALLET, + asset: "XLM", + amount: 5000, + interestRate: 11.25, + duration: 14, + collateralAsset: "USDC", + collateralAmount: 8000, + fundedAmount: 0, + outstandingDebt: 0, + createdAt: new Date(Date.now() - 86400000).toISOString(), + updatedAt: new Date(Date.now() - 3600000).toISOString(), + maturityDate: new Date(Date.now() + 86400000 * 9).toISOString(), + transactionHash: "b".repeat(64), + }, +}; + +function normalizeCommitmentId(rawId: unknown): string { + return typeof rawId === "string" ? rawId.trim() : ""; +} + +function isValidCommitmentId(id: string): boolean { + return typeof id === "string" && id.length > 1 && VALID_COMMITMENT_ID.test(id); +} + /** * Simulate transaction processing delay */ @@ -27,29 +80,63 @@ export async function POST( { params }: { params: Promise<{ id: string }> }, ) { try { - const { id } = await params; - const body: CommitmentActionRequest = await request.json(); + const user = await getUser(); + if (!user || !user.walletAddress) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id: routeId } = await params; + const routeCommitmentId = normalizeCommitmentId(routeId); - const { action } = body; + if (!isValidCommitmentId(routeCommitmentId)) { + return NextResponse.json({ error: "Invalid commitment id" }, { status: 400 }); + } + + const body = await request.json(); + if (!body || typeof body !== "object" || Array.isArray(body)) { + return NextResponse.json({ error: "Invalid action payload" }, { status: 400 }); + } + + const payload = body as Partial; + const action = payload.action; + + if (typeof payload.commitmentId !== "string") { + return NextResponse.json({ error: "Invalid action payload" }, { status: 400 }); + } + + const commitmentId = normalizeCommitmentId(payload.commitmentId); + if (commitmentId !== routeCommitmentId) { + return NextResponse.json({ error: "Commitment id mismatch" }, { status: 400 }); + } if (!action || !["fund", "dispute", "early_exit", "settle"].includes(action)) { - return NextResponse.json( - { - success: false, - error: { - code: "INVALID_ACTION", - message: "Invalid action type", - }, - }, - { status: 400 }, - ); + return NextResponse.json({ error: "Invalid action payload" }, { status: 400 }); + } + + if ( + payload.metadata !== undefined && + (typeof payload.metadata !== "object" || Array.isArray(payload.metadata) || payload.metadata === null) + ) { + return NextResponse.json({ error: "Invalid action payload" }, { status: 400 }); + } + + if ( + payload.signedEnvelopeXdr !== undefined && + typeof payload.signedEnvelopeXdr !== "string" + ) { + return NextResponse.json({ error: "Invalid action payload" }, { status: 400 }); + } + + const commitment = MOCK_COMMITMENTS[routeCommitmentId] ?? null; + if (!commitment) { + return NextResponse.json({ error: "Commitment not found" }, { status: 404 }); } - // In production, fetch current commitment state from database - // For now, we'll simulate based on the action - const currentStatus: CommitmentStatus = "active"; // Mock current state + if (user.walletAddress !== commitment.borrower && user.walletAddress !== commitment.lender) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } - // Validate action is allowed in current state + const currentStatus: CommitmentStatus = commitment.status; const allowedActions = COMMITMENT_STATE_MACHINE[currentStatus] || []; if (!allowedActions.includes(action)) { return NextResponse.json( @@ -64,10 +151,8 @@ export async function POST( ); } - // Simulate transaction processing - await delay(1000 + Math.random() * 2000); // 1-3 second delay + await delay(1000 + Math.random() * 2000); - // Determine new status based on action let newStatus: CommitmentStatus; switch (action) { case "fund": @@ -86,15 +171,9 @@ export async function POST( newStatus = currentStatus; } - // Generate mock transaction hash - const transactionHash = `${Date.now().toString(16)}${Math.random().toString(16).slice(2, 18)}`.padEnd( - 64, - "0", - ); - const response: CommitmentActionResponse = { success: true, - transactionHash, + transactionHash: "c".repeat(64), newStatus, }; diff --git a/app/api/commitments/[id]/route.test.ts b/app/api/commitments/[id]/route.test.ts new file mode 100644 index 00000000..4c7d021c --- /dev/null +++ b/app/api/commitments/[id]/route.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; +import { GET } from './route'; +import { POST } from './actions/route'; +import { getUser } from '@/lib/auth'; + +vi.mock('@/lib/auth', () => ({ + getUser: vi.fn(), +})); + +const mockGetUser = vi.mocked(getUser); + +const borrowerWallet = 'G' + 'A'.repeat(55); +const lenderWallet = 'G' + 'B'.repeat(55); + +function makeParams(id: string): { params: Promise<{ id: string }> } { + return { params: Promise.resolve({ id }) }; +} + +describe('commitment route security boundary', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('requires an authenticated user for GET detail requests', async () => { + mockGetUser.mockResolvedValueOnce(null); + + const req = new NextRequest('http://localhost/api/commitments/valid-id'); + const res = await GET(req, makeParams('valid-id')); + + expect(res.status).toBe(401); + expect(await res.json()).toMatchObject({ error: 'Unauthorized' }); + }); + + it('rejects invalid commitment ids before loading mock data', async () => { + mockGetUser.mockResolvedValueOnce({ id: 'user-1', walletAddress: borrowerWallet } as any); + + const req = new NextRequest('http://localhost/api/commitments/../../admin'); + const res = await GET(req, makeParams('../../admin')); + + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ error: 'Invalid commitment id' }); + }); + + it('forbids access when the authenticated wallet is not a party to the commitment', async () => { + mockGetUser.mockResolvedValueOnce({ id: 'user-1', walletAddress: 'G' + 'C'.repeat(55) } as any); + + const req = new NextRequest('http://localhost/api/commitments/commitment-123'); + const res = await GET(req, makeParams('commitment-123')); + + expect(res.status).toBe(403); + expect(await res.json()).toMatchObject({ error: 'Forbidden' }); + }); + + it('requires the same commitment id in the action payload as the route id', async () => { + mockGetUser.mockResolvedValueOnce({ id: 'user-1', walletAddress: borrowerWallet } as any); + + const req = new NextRequest('http://localhost/api/commitments/commitment-123/actions', { + method: 'POST', + body: JSON.stringify({ action: 'dispute', commitmentId: 'other-id' }), + headers: { 'Content-Type': 'application/json' }, + }); + + const res = await POST(req, makeParams('commitment-123')); + + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ error: 'Commitment id mismatch' }); + }); + + it('rejects malformed action payloads and unauthorized wallets', async () => { + mockGetUser.mockResolvedValueOnce({ id: 'user-1', walletAddress: borrowerWallet } as any); + + const req = new NextRequest('http://localhost/api/commitments/commitment-123/actions', { + method: 'POST', + body: JSON.stringify({ action: 'dispute', commitmentId: 'commitment-123', metadata: 'not-an-object' }), + headers: { 'Content-Type': 'application/json' }, + }); + + const res = await POST(req, makeParams('commitment-123')); + + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ error: 'Invalid action payload' }); + }); + + it('returns only safe commitment data for the authenticated wallet', async () => { + mockGetUser.mockResolvedValueOnce({ id: 'user-1', walletAddress: borrowerWallet } as any); + + const req = new NextRequest('http://localhost/api/commitments/commitment-123'); + const res = await GET(req, makeParams('commitment-123')); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.commitment.id).toBe('commitment-123'); + expect(data.commitment.borrower).toBe(borrowerWallet); + expect(data.commitment.transactionHash).toMatch(/^[0-9a-fA-F]{64}$/); + }); +}); diff --git a/app/api/commitments/[id]/route.ts b/app/api/commitments/[id]/route.ts index 59dcaa72..e7cb4ec5 100644 --- a/app/api/commitments/[id]/route.ts +++ b/app/api/commitments/[id]/route.ts @@ -4,6 +4,7 @@ */ import { NextResponse } from "next/server"; +import { getUser } from "@/lib/auth"; import type { Commitment, CommitmentDetailResponse, @@ -12,6 +13,89 @@ import type { } from "@/types/commitment"; import { COMMITMENT_STATE_MACHINE } from "@/types/commitment"; +const BORROWER_WALLET = "G" + "A".repeat(55); +const LENDER_WALLET = "G" + "B".repeat(55); + +const VALID_COMMITMENT_ID = /^[a-zA-Z0-9][a-zA-Z0-9-]{1,63}$/; + +const MOCK_COMMITMENTS: Record = { + "commitment-123": { + id: "commitment-123", + status: "active", + borrower: BORROWER_WALLET, + lender: LENDER_WALLET, + asset: "XLM", + amount: 10000, + interestRate: 12.5, + duration: 30, + collateralAsset: "USDC", + collateralAmount: 15000, + fundedAmount: 10000, + outstandingDebt: 10104.17, + createdAt: new Date(Date.now() - 86400000 * 5).toISOString(), + updatedAt: new Date(Date.now() - 3600000).toISOString(), + maturityDate: new Date(Date.now() + 86400000 * 25).toISOString(), + transactionHash: "a".repeat(64), + }, + "valid-id": { + id: "valid-id", + status: "pending", + borrower: BORROWER_WALLET, + lender: LENDER_WALLET, + asset: "XLM", + amount: 5000, + interestRate: 11.25, + duration: 14, + collateralAsset: "USDC", + collateralAmount: 8000, + fundedAmount: 0, + outstandingDebt: 0, + createdAt: new Date(Date.now() - 86400000).toISOString(), + updatedAt: new Date(Date.now() - 3600000).toISOString(), + maturityDate: new Date(Date.now() + 86400000 * 9).toISOString(), + transactionHash: "b".repeat(64), + }, +}; + +function normalizeCommitmentId(rawId: unknown): string { + return typeof rawId === "string" ? rawId.trim() : ""; +} + +function isValidCommitmentId(id: string): boolean { + return typeof id === "string" && id.length > 1 && VALID_COMMITMENT_ID.test(id); +} + +function getCanPerformActions(status: Commitment["status"]): Record { + const allowedActions = COMMITMENT_STATE_MACHINE[status] || []; + + return { + fund: { + allowed: allowedActions.includes("fund"), + reason: allowedActions.includes("fund") + ? undefined + : "Funding only available for pending commitments", + }, + dispute: { + allowed: allowedActions.includes("dispute"), + reason: allowedActions.includes("dispute") + ? undefined + : "Disputes can only be raised on active commitments", + }, + early_exit: { + allowed: allowedActions.includes("early_exit"), + reason: allowedActions.includes("early_exit") + ? undefined + : "Early exit only available for active commitments", + }, + settle: { + allowed: allowedActions.includes("settle"), + reason: allowedActions.includes("settle") + ? undefined + : "Settlement not available in current state", + }, + }; +} + /** * GET /api/commitments/[id] * Fetch commitment details and action permissions @@ -21,63 +105,30 @@ export async function GET( { params }: { params: Promise<{ id: string }> }, ) { try { - const { id } = await params; + const user = await getUser(); + if (!user || !user.walletAddress) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } - // In production, fetch from database or blockchain - // This is a mock implementation for demonstration - const mockCommitment: Commitment = { - id, - status: "active", - borrower: "GBXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - lender: "GCYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", - asset: "XLM", - amount: 10000, - interestRate: 12.5, - duration: 30, - collateralAsset: "USDC", - collateralAmount: 15000, - fundedAmount: 10000, - outstandingDebt: 10104.17, // Principal + accrued interest - createdAt: new Date(Date.now() - 86400000 * 5).toISOString(), // 5 days ago - updatedAt: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago - maturityDate: new Date(Date.now() + 86400000 * 25).toISOString(), // 25 days from now - transactionHash: "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", - }; + const { id: rawId } = await params; + const id = normalizeCommitmentId(rawId); - // Determine allowed actions based on state machine - const allowedActions = COMMITMENT_STATE_MACHINE[mockCommitment.status] || []; + if (!isValidCommitmentId(id)) { + return NextResponse.json({ error: "Invalid commitment id" }, { status: 400 }); + } - // Check authorization for each action - const canPerformActions: Record = { - fund: { - allowed: allowedActions.includes("fund"), - reason: allowedActions.includes("fund") - ? undefined - : "Funding only available for pending commitments", - }, - dispute: { - allowed: allowedActions.includes("dispute"), - reason: allowedActions.includes("dispute") - ? undefined - : "Disputes can only be raised on active commitments", - }, - early_exit: { - allowed: allowedActions.includes("early_exit"), - reason: allowedActions.includes("early_exit") - ? undefined - : "Early exit only available for active commitments", - }, - settle: { - allowed: allowedActions.includes("settle"), - reason: allowedActions.includes("settle") - ? undefined - : "Settlement not available in current state", - }, - }; + const commitment = MOCK_COMMITMENTS[id] ?? null; + if (!commitment) { + return NextResponse.json({ error: "Commitment not found" }, { status: 404 }); + } + + if (user.walletAddress !== commitment.borrower && user.walletAddress !== commitment.lender) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } const response: CommitmentDetailResponse = { - commitment: mockCommitment, - canPerformActions, + commitment, + canPerformActions: getCanPerformActions(commitment.status), }; return NextResponse.json(response, {