From c8c795f343d1aaed1c02dddeb20f79b95d4b79c9 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:07:41 -0400 Subject: [PATCH 01/44] fix(test): upgrade mongodb-memory-server to 11 and pin MongoDB 8.0.17 mongodb-memory-server 8.x defaults to MongoDB 5.0.19, whose only Linux builds target Ubuntu 20.04 and link against OpenSSL 1.1. GitHub's ubuntu-latest is now 24.04, which ships OpenSSL 3 only, so every core run died in globalSetup with `StdoutInstanceError: ... "libcrypto.so.1.1"` and no core suite ran. mongodb-memory-server 11 resolves native ubuntu2404 builds for MongoDB 8.x. TestMongo now pins the binary (8.0.17) explicitly instead of inheriting the library default, so a future library bump can't silently change the server version; MONGOMS_VERSION still overrides it. The lockfile change is limited to the mongodb-memory-server subtree (and its mongodb driver/bson); unrelated `peer` flag churn from a different npm version was left out. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- package-lock.json | 301 +++++++++++++++++++++---------------- packages/test/mongodb.js | 12 +- packages/test/package.json | 2 +- 3 files changed, 185 insertions(+), 130 deletions(-) diff --git a/package-lock.json b/package-lock.json index a7a4cb72c..ea5ee6235 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5922,11 +5922,10 @@ } }, "node_modules/@mongodb-js/saslprep": { - "version": "1.4.11", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.11.tgz", - "integrity": "sha512-o9rAHc0IpIjuPSxRutWpE1F62x7n+4mVS4rCNHkzhIUMQcc18bb6xEq5wd2NdN0WjepIyXIppRshYI2kQDOZVA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.6.1.tgz", + "integrity": "sha512-D4CQtk9IwjCLAvCf1xvHEod3960q8XMUzfmdRU9qjUNlHnesGuTOvJcOTaPm9koZ1caUKA2aTbszZiAkcmSgmg==", "license": "MIT", - "optional": true, "dependencies": { "sparse-bitfield": "^3.0.3" } @@ -10494,12 +10493,11 @@ "license": "MIT" }, "node_modules/@types/whatwg-url": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz", - "integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", "license": "MIT", "dependencies": { - "@types/node": "*", "@types/webidl-conversions": "*" } }, @@ -11996,12 +11994,12 @@ } }, "node_modules/async-mutex": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.3.2.tgz", - "integrity": "sha512-HuTK7E7MT7jZEh1P9GtRW9+aTWiDWWi9InbZ5hjxrnRa39KS4BW04+xLBhYNS2aXhHUIKZSw3gj4Pn1pj+qGAA==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", "license": "MIT", "dependencies": { - "tslib": "^2.3.1" + "tslib": "^2.4.0" } }, "node_modules/async-mutex/node_modules/tslib": { @@ -12488,7 +12486,6 @@ "version": "2.9.1", "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", - "dev": true, "license": "Apache-2.0", "peerDependencies": { "bare-abort-controller": "*" @@ -12500,10 +12497,9 @@ } }, "node_modules/bare-fs": { - "version": "4.7.2", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.2.tgz", - "integrity": "sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==", - "dev": true, + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.4.tgz", + "integrity": "sha512-MPC2eyrpbk3A873sK4y6rk+Iv4o2enxTjHZ8zH3HO0mjrpm5XjnguY2XmfidIsDV6vnjpbMBKUAJpV0ISD2SsQ==", "license": "Apache-2.0", "dependencies": { "bare-events": "^2.5.4", @@ -12513,7 +12509,7 @@ "fast-fifo": "^1.3.2" }, "engines": { - "bare": ">=1.16.0" + "bare": ">=1.28.0" }, "peerDependencies": { "bare-buffer": "*" @@ -12528,7 +12524,6 @@ "version": "3.9.1", "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz", "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==", - "dev": true, "license": "Apache-2.0", "engines": { "bare": ">=1.14.0" @@ -12538,7 +12533,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.1.tgz", "integrity": "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "bare-os": "^3.0.1" @@ -12548,7 +12542,6 @@ "version": "2.13.3", "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "b4a": "^1.8.1", @@ -12576,7 +12569,6 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", - "dev": true, "license": "Apache-2.0", "peerDependencies": { "react-native-b4a": "*" @@ -12591,7 +12583,6 @@ "version": "2.4.5", "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "bare-path": "^3.0.0" @@ -13083,6 +13074,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, "license": "MIT", "dependencies": { "buffer": "^5.5.0", @@ -13421,6 +13413,7 @@ "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, "license": "MIT", "engines": { "node": "*" @@ -16193,33 +16186,11 @@ "node": ">= 0.8" } }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -17495,7 +17466,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "bare-events": "^2.7.0" @@ -17726,7 +17696,6 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -17819,6 +17788,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, "license": "MIT", "dependencies": { "pend": "~1.2.0" @@ -18353,6 +18323,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, "license": "MIT" }, "node_modules/fs-extra": { @@ -18694,6 +18665,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -19669,6 +19641,7 @@ "version": "10.5.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 12" @@ -24715,18 +24688,6 @@ "node": ">= 0.4" } }, - "node_modules/md5-file": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/md5-file/-/md5-file-5.0.0.tgz", - "integrity": "sha512-xbEFXCYVWrSx/gEKS1VPlg84h/4L20znVIulKw6kMfmBUAZNAnF00eczz9ICMl+/hjQGo5KSXRxbL/47X3rmMw==", - "license": "MIT", - "bin": { - "md5-file": "cli.js" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/mdast-util-from-markdown": { "version": "0.8.5", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-0.8.5.tgz", @@ -24794,8 +24755,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/meow": { "version": "8.1.2", @@ -25295,43 +25255,74 @@ "license": "MIT" }, "node_modules/mongodb": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.17.2.tgz", - "integrity": "sha512-mLV7SEiov2LHleRJPMPrK2PMyhXFZt2UQLC4VD4pnth3jMjYKHhtqfwwkkvS/NXuo/Fp3vbhaNcXrIDaLRb9Tg==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.5.0.tgz", + "integrity": "sha512-5FnrEDLnvp6ycUOGLNLLU33BfCx2qmp2mJjGPDwKLruYsVzXVSK5fsGpoDXvsXJwBfBsD7ebMRdawbDxC2814g==", "license": "Apache-2.0", "dependencies": { - "bson": "^4.7.2", - "mongodb-connection-string-url": "^2.6.0", - "socks": "^2.7.1" + "@mongodb-js/saslprep": "^1.4.11", + "bson": "^7.2.0", + "mongodb-connection-string-url": "^7.0.1" }, "engines": { - "node": ">=12.9.0" + "node": ">=20.19.0" }, - "optionalDependencies": { - "@aws-sdk/credential-providers": "^3.186.0", - "@mongodb-js/saslprep": "^1.1.0" + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.806.0", + "@mongodb-js/zstd": "^7.0.0", + "gcp-metadata": "^7.0.1", + "kerberos": "^7.0.0", + "mongodb-client-encryption": "^7.2.0", + "snappy": "^7.3.2", + "socks": "^2.8.6" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } } }, "node_modules/mongodb-connection-string-url": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz", - "integrity": "sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.2.tgz", + "integrity": "sha512-ZoS07RoFqpKYQwAk59qmrx8+jJHNHU30UjlU96QktiGn1ltvDr+vCznLX5DiUBLEpMAHatHNWV1nM/74ul66kA==", "license": "Apache-2.0", "dependencies": { - "@types/whatwg-url": "^8.2.1", - "whatwg-url": "^11.0.0" + "@types/whatwg-url": "^13.0.0", + "whatwg-url": "^14.1.0" + }, + "engines": { + "node": ">=20.19.0" } }, "node_modules/mongodb-connection-string-url/node_modules/tr46": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "license": "MIT", "dependencies": { - "punycode": "^2.1.1" + "punycode": "^2.3.1" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/mongodb-connection-string-url/node_modules/webidl-conversions": { @@ -25344,56 +25335,76 @@ } }, "node_modules/mongodb-connection-string-url/node_modules/whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "license": "MIT", "dependencies": { - "tr46": "^3.0.0", + "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/mongodb-memory-server": { - "version": "8.16.1", - "resolved": "https://registry.npmjs.org/mongodb-memory-server/-/mongodb-memory-server-8.16.1.tgz", - "integrity": "sha512-Zje3i+xKN+nxALkOOraDfIvc9X8mNy979IvJdjUghvf5PbwvX5ZPr5gUtCcmzz2VRj97WsZbdUSkxny+GXZTIA==", + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/mongodb-memory-server/-/mongodb-memory-server-11.3.0.tgz", + "integrity": "sha512-8ZCDrraqKqmAikoeKuEq+16jeWdVKwjtEAuMxxeQctHiGSGYt17W9LmoRzV1Nwnz7owyxwDKMhow4IVkNYYRKw==", "hasInstallScript": true, "license": "MIT", "dependencies": { - "mongodb-memory-server-core": "8.16.1", - "tslib": "^2.6.1" + "mongodb-memory-server-core": "11.3.0", + "tslib": "^2.8.1" }, "engines": { - "node": ">=12.22.0" + "node": ">=20.19.0" } }, "node_modules/mongodb-memory-server-core": { - "version": "8.16.1", - "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-8.16.1.tgz", - "integrity": "sha512-skRGr7vzVIyefKm/YTn73sWI/7ghIb+gBxYNt42kGO7zeOfy+3S2Xg3kHYLkBz1IrOmTyV2HpFVzbZ1HF8grsQ==", + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-11.3.0.tgz", + "integrity": "sha512-QBu/RsRpBfcNd5CrBDfCzqcaFPaYpivUzGYUy9YgVc3up78ff0gIHQVD9PLmBLOOxFX6BM9hU6YT0HocZH+s1g==", "license": "MIT", "dependencies": { - "async-mutex": "^0.3.2", + "async-mutex": "^0.5.0", "camelcase": "^6.3.0", - "debug": "^4.3.4", + "debug": "^4.4.3", "find-cache-dir": "^3.3.2", - "follow-redirects": "^1.15.2", - "get-port": "^5.1.1", - "https-proxy-agent": "^5.0.1", - "md5-file": "^5.0.0", - "mongodb": "^4.16.0", + "follow-redirects": "^1.16.0", + "https-proxy-agent": "^7.0.6", + "mongodb": "~7.5.0", "new-find-package-json": "^2.0.0", - "semver": "^7.5.4", - "tar-stream": "^2.1.4", - "tslib": "^2.6.1", - "uuid": "^9.0.0", - "yauzl": "^2.10.0" + "semver": "^7.8.5", + "tar-stream": "^3.2.1", + "tslib": "^2.8.1", + "yauzl": "^3.4.0" }, "engines": { - "node": ">=12.22.0" + "node": ">=20.19.0" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/b4a": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.9.0.tgz", + "integrity": "sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } } }, "node_modules/mongodb-memory-server-core/node_modules/camelcase": { @@ -25408,23 +25419,47 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mongodb-memory-server-core/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/tar-stream": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.2.tgz", + "integrity": "sha512-+8NeqHRjQWH9nYlwo2gamAMImZCVzI4UoEgDpWorBt9OEfppiZn+uSkskzQKPWIZyji/C8fpWO7u69G0DX0tbg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.9.0", + "bare-fs": "^4.8.2", + "fast-fifo": "^1.3.2", + "streamx": "^2.28.1" + } + }, "node_modules/mongodb-memory-server-core/node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, - "node_modules/mongodb-memory-server-core/node_modules/uuid": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", - "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], + "node_modules/mongodb-memory-server-core/node_modules/yauzl": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" + "dependencies": { + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" } }, "node_modules/mongodb-memory-server/node_modules/tslib": { @@ -25433,6 +25468,15 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/mongodb/node_modules/bson": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/bson/-/bson-7.3.3.tgz", + "integrity": "sha512-oz3LwE3qGEhTCSyhzQRLY3Bj1NLOsjFMf6tcmY5J8M+3R7EVcmoBfSHWvjUK3yXCLDUsGpIuB3aQL4nDZ1EV/g==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -28289,6 +28333,7 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -30142,6 +30187,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 6.0.0", @@ -30152,6 +30198,7 @@ "version": "2.8.9", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "devOptional": true, "license": "MIT", "dependencies": { "ip-address": "^10.1.1", @@ -30268,7 +30315,6 @@ "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", "license": "MIT", - "optional": true, "dependencies": { "memory-pager": "^1.0.2" } @@ -30457,10 +30503,9 @@ } }, "node_modules/streamx": { - "version": "2.28.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", - "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", - "dev": true, + "version": "2.28.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz", + "integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==", "license": "MIT", "dependencies": { "events-universal": "^1.0.0", @@ -30472,6 +30517,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -31244,6 +31290,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, "license": "MIT", "dependencies": { "bl": "^4.0.3", @@ -31296,7 +31343,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", - "dev": true, "license": "MIT", "dependencies": { "streamx": "^2.12.5" @@ -31347,7 +31393,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.4" @@ -31357,7 +31402,6 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", - "dev": true, "license": "Apache-2.0", "peerDependencies": { "react-native-b4a": "*" @@ -33576,6 +33620,7 @@ "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, "license": "MIT", "dependencies": { "buffer-crc32": "~0.2.3", @@ -35200,7 +35245,7 @@ "eslint-plugin-no-only-tests": "^3.0.0", "eslint-plugin-yaml": "^0.5.0", "jest-runner-groups": "^2.2.0", - "mongodb-memory-server": "^8.9.0", + "mongodb-memory-server": "^11.3.0", "open": "^8.4.2" }, "devDependencies": { diff --git a/packages/test/mongodb.js b/packages/test/mongodb.js index 320b06955..e5492618a 100644 --- a/packages/test/mongodb.js +++ b/packages/test/mongodb.js @@ -1,11 +1,20 @@ const { MongoMemoryServer } = require('mongodb-memory-server'); +// Pin the MongoDB binary instead of relying on the library default. The old +// default (5.0.19) only ships Ubuntu 20.04 builds that link against OpenSSL +// 1.1 (libcrypto.so.1.1), which Ubuntu 24.04 (GitHub's ubuntu-latest) no +// longer ships, so the server failed to start in CI. MongoDB 8.0 has native +// Ubuntu 24.04 builds. MONGOMS_VERSION still overrides this for local use. +const MONGODB_BINARY_VERSION = process.env.MONGOMS_VERSION || '8.0.17'; + class TestMongo { #mongoServer; // Start the in-memory mongo instance and set env variable for the app to use in its connection. async start() { - this.#mongoServer = await MongoMemoryServer.create(); + this.#mongoServer = await MongoMemoryServer.create({ + binary: { version: MONGODB_BINARY_VERSION }, + }); process.env.MONGO_URI = this.#mongoServer.getUri(); console.log('Started in memory mongo server', process.env.MONGO_URI); } @@ -19,4 +28,5 @@ class TestMongo { module.exports = { TestMongo, + MONGODB_BINARY_VERSION, }; diff --git a/packages/test/package.json b/packages/test/package.json index 8108b7ad5..943c3ce36 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -11,7 +11,7 @@ "eslint-plugin-no-only-tests": "^3.0.0", "eslint-plugin-yaml": "^0.5.0", "jest-runner-groups": "^2.2.0", - "mongodb-memory-server": "^8.9.0", + "mongodb-memory-server": "^11.3.0", "open": "^8.4.2" }, "devDependencies": { From 7078198394b7894c99857030665fe8bfd83b3d6d Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:09:44 -0400 Subject: [PATCH 02/44] test(core): stop starting an in-memory MongoDB for every core test run Core's jest globalSetup delegated to @friggframework/test, which starts a MongoMemoryServer and exports MONGO_URI before any suite runs. Nothing in the default core suite talks to that server (repository tests mock Prisma; prisma.test.js only checks env handling), yet when the binary couldn't start the whole core suite (~200 files) was skipped. Unit suites must not need a database. The core setup now only applies the STAGE=dev default and restores the environment on teardown. TestMongo and @friggframework/test's own globalSetup are unchanged for apps that opt in. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- ... mongo-decryption-fix-verification.db.test.js} | 0 ...stgres-decryption-fix-verification.db.test.js} | 0 ...js => postgres-relation-decryption.db.test.js} | 0 packages/core/jest-setup.js | 15 +++++++++++++-- packages/core/jest-teardown.js | 7 +++++-- 5 files changed, 18 insertions(+), 4 deletions(-) rename packages/core/database/encryption/{mongo-decryption-fix-verification.test.js => mongo-decryption-fix-verification.db.test.js} (100%) rename packages/core/database/encryption/{postgres-decryption-fix-verification.test.js => postgres-decryption-fix-verification.db.test.js} (100%) rename packages/core/database/encryption/{postgres-relation-decryption.test.js => postgres-relation-decryption.db.test.js} (100%) diff --git a/packages/core/database/encryption/mongo-decryption-fix-verification.test.js b/packages/core/database/encryption/mongo-decryption-fix-verification.db.test.js similarity index 100% rename from packages/core/database/encryption/mongo-decryption-fix-verification.test.js rename to packages/core/database/encryption/mongo-decryption-fix-verification.db.test.js diff --git a/packages/core/database/encryption/postgres-decryption-fix-verification.test.js b/packages/core/database/encryption/postgres-decryption-fix-verification.db.test.js similarity index 100% rename from packages/core/database/encryption/postgres-decryption-fix-verification.test.js rename to packages/core/database/encryption/postgres-decryption-fix-verification.db.test.js diff --git a/packages/core/database/encryption/postgres-relation-decryption.test.js b/packages/core/database/encryption/postgres-relation-decryption.db.test.js similarity index 100% rename from packages/core/database/encryption/postgres-relation-decryption.test.js rename to packages/core/database/encryption/postgres-relation-decryption.db.test.js diff --git a/packages/core/jest-setup.js b/packages/core/jest-setup.js index 9dd3e0d42..b1fe30d53 100644 --- a/packages/core/jest-setup.js +++ b/packages/core/jest-setup.js @@ -1,2 +1,13 @@ -const {globalSetup} = require('@friggframework/test'); -module.exports = globalSetup; +// Core's unit suites must not need a database, so this global setup only +// sets the test environment. It deliberately does not start an in-memory +// MongoDB (as @friggframework/test's globalSetup does): nothing in the default +// suite talks to a live server, and a binary that failed to start used to stop +// every core suite from running. Suites that need a real database are +// `*.db.test.js` files, run by `npm run test:db` (see jest.db.config.js). +const { overrideEnvironment } = require('@friggframework/test'); + +module.exports = async function () { + if (!process.env.STAGE) { + overrideEnvironment({ STAGE: 'dev' }); + } +}; diff --git a/packages/core/jest-teardown.js b/packages/core/jest-teardown.js index 5bc725108..5e7017fd4 100644 --- a/packages/core/jest-teardown.js +++ b/packages/core/jest-teardown.js @@ -1,2 +1,5 @@ -const {globalTeardown} = require('@friggframework/test'); -module.exports = globalTeardown; +const { restoreEnvironment } = require('@friggframework/test'); + +module.exports = async function () { + restoreEnvironment(); +}; From fdfe766a3645adca22d76a8946893e888a2e030b Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:09:44 -0400 Subject: [PATCH 03/44] test(core): move real-database suites to *.db.test.js and `npm run test:db` Three encryption suites connect to a live MongoDB replica set or PostgreSQL through generated Prisma clients instead of mocking Prisma: mongo-decryption-fix-verification, postgres-decryption-fix-verification and postgres-relation-decryption. In the default run they failed with "Cannot find Prisma client" (or would have needed a database), so they are now DB integration tests: - renamed to *.db.test.js and ignored by the default jest config; - run by `npm run test:db` (jest.db.config.js, runInBand); - connection strings come from FRIGG_TEST_MONGODB_URL / FRIGG_TEST_POSTGRES_URL (defaults unchanged), so one run can reach both databases without fighting over DATABASE_URL. All 16 tests pass against mongo:8.0 (replica set) and postgres:16. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- packages/core/database/encryption/README.md | 6 +++--- ...ngo-decryption-fix-verification.db.test.js | 2 +- ...res-decryption-fix-verification.db.test.js | 2 +- .../postgres-relation-decryption.db.test.js | 2 +- packages/core/jest.config.js | 4 ++++ packages/core/jest.db.config.js | 21 +++++++++++++++++++ packages/core/package.json | 1 + 7 files changed, 32 insertions(+), 6 deletions(-) create mode 100644 packages/core/jest.db.config.js diff --git a/packages/core/database/encryption/README.md b/packages/core/database/encryption/README.md index 8c609caf2..0623433e9 100644 --- a/packages/core/database/encryption/README.md +++ b/packages/core/database/encryption/README.md @@ -508,9 +508,9 @@ return entities.map((e) => ({ **Verified:** -- ✅ `postgres-relation-decryption.test.js` - Proves the bug exists -- ✅ `postgres-decryption-fix-verification.test.js` - Verifies separate queries work -- ✅ `mongo-decryption-fix-verification.test.js` - Verifies fix for MongoDB +- ✅ `postgres-relation-decryption.db.test.js` - Proves the bug exists +- ✅ `postgres-decryption-fix-verification.db.test.js` - Verifies separate queries work +- ✅ `mongo-decryption-fix-verification.db.test.js` - Verifies fix for MongoDB **Implementation Examples:** diff --git a/packages/core/database/encryption/mongo-decryption-fix-verification.db.test.js b/packages/core/database/encryption/mongo-decryption-fix-verification.db.test.js index f716f921f..5c9b372d6 100644 --- a/packages/core/database/encryption/mongo-decryption-fix-verification.db.test.js +++ b/packages/core/database/encryption/mongo-decryption-fix-verification.db.test.js @@ -10,7 +10,7 @@ */ process.env.DB_TYPE = 'mongodb'; -process.env.DATABASE_URL = process.env.DATABASE_URL || 'mongodb://localhost:27017/frigg?replicaSet=rs0'; +process.env.DATABASE_URL = process.env.FRIGG_TEST_MONGODB_URL || 'mongodb://localhost:27017/frigg?replicaSet=rs0'; process.env.STAGE = 'integration-test'; process.env.AES_KEY_ID = 'test-key-id'; process.env.AES_KEY = 'test-aes-key-32-characters-long!'; diff --git a/packages/core/database/encryption/postgres-decryption-fix-verification.db.test.js b/packages/core/database/encryption/postgres-decryption-fix-verification.db.test.js index da549fc96..3966abfcc 100644 --- a/packages/core/database/encryption/postgres-decryption-fix-verification.db.test.js +++ b/packages/core/database/encryption/postgres-decryption-fix-verification.db.test.js @@ -11,7 +11,7 @@ // Set up test environment for PostgreSQL with encryption process.env.DB_TYPE = 'postgresql'; -process.env.DATABASE_URL = process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/frigg?schema=public'; +process.env.DATABASE_URL = process.env.FRIGG_TEST_POSTGRES_URL || 'postgresql://postgres:postgres@localhost:5432/frigg?schema=public'; process.env.STAGE = 'integration-test'; process.env.AES_KEY_ID = 'test-key-id'; process.env.AES_KEY = 'test-aes-key-32-characters-long!'; diff --git a/packages/core/database/encryption/postgres-relation-decryption.db.test.js b/packages/core/database/encryption/postgres-relation-decryption.db.test.js index a3fd4e7c9..da4c4e496 100644 --- a/packages/core/database/encryption/postgres-relation-decryption.db.test.js +++ b/packages/core/database/encryption/postgres-relation-decryption.db.test.js @@ -13,7 +13,7 @@ // Set up test environment for PostgreSQL with encryption process.env.DB_TYPE = 'postgresql'; -process.env.DATABASE_URL = process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/frigg?schema=public'; +process.env.DATABASE_URL = process.env.FRIGG_TEST_POSTGRES_URL || 'postgresql://postgres:postgres@localhost:5432/frigg?schema=public'; process.env.STAGE = 'integration-test'; process.env.AES_KEY_ID = 'test-key-id'; process.env.AES_KEY = 'test-aes-key-32-characters-long!'; diff --git a/packages/core/jest.config.js b/packages/core/jest.config.js index dcfe165d8..8f2471baa 100644 --- a/packages/core/jest.config.js +++ b/packages/core/jest.config.js @@ -20,4 +20,8 @@ module.exports = { globalTeardown: './jest-teardown.js', setupFilesAfterEnv: ['./logs/jest-logger-setup.js'], + + // `*.db.test.js` suites talk to a real MongoDB replica set / PostgreSQL + // and run separately via `npm run test:db` (jest.db.config.js). + testPathIgnorePatterns: ['/node_modules/', '\\.db\\.test\\.js$'], }; diff --git a/packages/core/jest.db.config.js b/packages/core/jest.db.config.js new file mode 100644 index 000000000..82d066d00 --- /dev/null +++ b/packages/core/jest.db.config.js @@ -0,0 +1,21 @@ +/* + * Database integration suites (`*.db.test.js`). + * + * These talk to real databases instead of mocking Prisma, so they are kept + * out of the default `npm test` run and executed with `npm run test:db`. + * They need generated Prisma clients (`npm run prisma:generate`) and: + * - a MongoDB replica set (Prisma requires one for MongoDB writes): + * FRIGG_TEST_MONGODB_URL, default + * mongodb://localhost:27017/frigg?replicaSet=rs0 + * - PostgreSQL with the prisma-postgresql migrations applied: + * FRIGG_TEST_POSTGRES_URL, default + * postgresql://postgres:postgres@localhost:5432/frigg?schema=public + */ +const base = require('./jest.config'); + +module.exports = { + ...base, + coverageThreshold: undefined, + testMatch: ['**/*.db.test.js'], + testPathIgnorePatterns: ['/node_modules/'], +}; diff --git a/packages/core/package.json b/packages/core/package.json index 27513db7e..64ccc8882 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -68,6 +68,7 @@ "scripts": { "lint:fix": "prettier --write --loglevel error . && eslint . --fix", "test": "jest --passWithNoTests # TODO", + "test:db": "jest --config jest.db.config.js --runInBand", "prisma:generate:mongo": "npx prisma generate --schema ./prisma-mongodb/schema.prisma", "prisma:generate:postgres": "npx prisma generate --schema ./prisma-postgresql/schema.prisma", "prisma:generate": "npm run prisma:generate:mongo && npm run prisma:generate:postgres", From 2a2adffba9d24a24c7051f5b22f2d14ea9ac1aa2 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:11:24 -0400 Subject: [PATCH 04/44] fix(core): actually load API-module credential encryption fields loadModuleEncryptionSchemas required '../integrations/utils/map-integration-dto' from database/encryption/, which resolves to a path that doesn't exist. loadCustomEncryptionSchema swallows errors at debug level, so module-level `encryption.credentialFields` declarations were silently never registered and those credential fields were stored unencrypted. The tests for this (never run in CI because core's globalSetup failed) caught it. With the path fixed, two more problems surfaced and are fixed here: - A module listing a field the core schema already encrypts (e.g. `api_key`) failed validation ("Cannot override core encrypted field"), dropping every field from every module. Core fields are now filtered out. - registerCustomSchema replaces the custom schema, so module fields would have wiped the app's own `encryption.schema`. Module fields are now merged into the existing custom schema. Integrations without `Definition.modules` are skipped instead of throwing. Values already stored in plaintext still read back: decryption passes through values that aren't in the encrypted format. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- .../encryption-schema-registry.test.js | 31 +++++++++++++++++ .../encryption/encryption-schema-registry.js | 34 +++++++++++++++---- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/packages/core/database/encryption/__tests__/encryption-schema-registry.test.js b/packages/core/database/encryption/__tests__/encryption-schema-registry.test.js index 54c64e8bc..d50d088e8 100644 --- a/packages/core/database/encryption/__tests__/encryption-schema-registry.test.js +++ b/packages/core/database/encryption/__tests__/encryption-schema-registry.test.js @@ -558,5 +558,36 @@ describe('encryption-schema-registry', () => { expect(credentialFields).toContain('data.custom_field'); // From custom schema expect(credentialFields).toContain('data.api_key'); // From module schema }); + + it('keeps app custom schema when modules add non-core fields', () => { + registerCustomSchema({ + Credential: { fields: ['data.custom_field'] }, + MyModel: { fields: ['secret'] }, + }); + + loadModuleEncryptionSchemas([ + { + Definition: { + modules: { + testModule: { + definition: { + encryption: { + credentialFields: ['api_key', 'webhook_secret'], + }, + }, + }, + }, + }, + }, + ]); + + const credentialFields = getEncryptedFields('Credential'); + expect(credentialFields).toContain('data.custom_field'); + expect(credentialFields).toContain('data.webhook_secret'); + expect( + credentialFields.filter((f) => f === 'data.api_key') + ).toHaveLength(1); + expect(getEncryptedFields('MyModel')).toEqual(['secret']); + }); }); }); diff --git a/packages/core/database/encryption/encryption-schema-registry.js b/packages/core/database/encryption/encryption-schema-registry.js index 9f92cc342..9b35a0e3c 100644 --- a/packages/core/database/encryption/encryption-schema-registry.js +++ b/packages/core/database/encryption/encryption-schema-registry.js @@ -174,26 +174,46 @@ function loadModuleEncryptionSchemas(integrations) { return; } - const { getModulesDefinitionFromIntegrationClasses } = require('../integrations/utils/map-integration-dto'); + const { getModulesDefinitionFromIntegrationClasses } = require('../../integrations/utils/map-integration-dto'); - const moduleDefinitions = getModulesDefinitionFromIntegrationClasses(integrations); - const credentialFields = extractCredentialFieldsFromModules(moduleDefinitions); + // Integrations without modules contribute no credential fields. + const integrationsWithModules = integrations.filter( + (integration) => integration?.Definition?.modules + ); + const moduleDefinitions = getModulesDefinitionFromIntegrationClasses( + integrationsWithModules + ); + + // Fields the core schema already encrypts (e.g. data.api_key) are + // covered; registering them again would fail validation and drop every + // other module field with them. + const coreCredentialFields = CORE_ENCRYPTION_SCHEMA.Credential.fields; + const credentialFields = extractCredentialFieldsFromModules( + moduleDefinitions + ).filter((field) => !coreCredentialFields.includes(field)); if (credentialFields.length === 0) { return; } - const moduleSchema = { + // Merge with whatever custom schema the app already registered rather + // than replacing it (registerCustomSchema overwrites). + const existingCredentialFields = customSchema.Credential?.fields || []; + const mergedSchema = { + ...customSchema, Credential: { - fields: credentialFields - } + ...customSchema.Credential, + fields: [ + ...new Set([...existingCredentialFields, ...credentialFields]), + ], + }, }; logger.info( `Registering module-level encryption for ${credentialFields.length} credential fields` ); - registerCustomSchema(moduleSchema); + registerCustomSchema(mergedSchema); } /** From bccef86b94862f54a486b19b0b80165a0846ed26 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:11:24 -0400 Subject: [PATCH 05/44] test(core): expect the API-key credential fields in the core schema 9d746f15 (feat(encryption): add API key encryption) added data.api_key, data.apiKey, data.API_KEY_VALUE, data.password and data.client_secret to CORE_ENCRYPTION_SCHEMA.Credential on purpose; this assertion still listed only the three OAuth token fields. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- .../database/encryption/encryption-schema-registry.test.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/core/database/encryption/encryption-schema-registry.test.js b/packages/core/database/encryption/encryption-schema-registry.test.js index 85895b3a0..d613513a6 100644 --- a/packages/core/database/encryption/encryption-schema-registry.test.js +++ b/packages/core/database/encryption/encryption-schema-registry.test.js @@ -60,6 +60,11 @@ describe('Encryption Schema Registry', () => { 'data.access_token', 'data.refresh_token', 'data.id_token', + 'data.api_key', + 'data.apiKey', + 'data.API_KEY_VALUE', + 'data.password', + 'data.client_secret', ]); }); From 134ff2ac14a12b2c2d6a906491bae18580ae7b36 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:12:22 -0400 Subject: [PATCH 06/44] test(core): make the DocumentDB encryption test use the real envelope format DocumentDBEncryptionService only encrypts/decrypts values it recognises as Cryptor envelopes (four base64 parts, > 50 chars) and only for fields in the schema registry. The test's mock cryptor emitted "encrypted:" and the suite assumed User.username was encrypted without registering it, so 11 assertions failed once core tests ran again. The mock now emits a keyId:iv:ciphertext:encryptedKey envelope (plaintext as the base64 ciphertext) and the suite registers User.username as a custom field, the way an app opts in via appDefinition.encryption.schema. Assertions are otherwise unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- .../documentdb-encryption-service.test.js | 66 ++++++++++++------- 1 file changed, 43 insertions(+), 23 deletions(-) diff --git a/packages/core/database/__tests__/documentdb-encryption-service.test.js b/packages/core/database/__tests__/documentdb-encryption-service.test.js index d5e4ac0b9..a33428e3d 100644 --- a/packages/core/database/__tests__/documentdb-encryption-service.test.js +++ b/packages/core/database/__tests__/documentdb-encryption-service.test.js @@ -2,10 +2,23 @@ const { DocumentDBEncryptionService, } = require('../documentdb-encryption-service'); const { + registerCustomSchema, + resetCustomSchema, registerEncryptionOptOut, resetEncryptionOptOut, } = require('../encryption/encryption-schema-registry'); +// The service only treats values in the Cryptor envelope format +// (keyId:iv:ciphertext:encryptedKey, all base64, > 50 chars) as encrypted, +// so the mock cryptor emits that shape with the plaintext as the ciphertext. +const MOCK_KEY_ID = Buffer.from('mock-key-id').toString('base64'); +const MOCK_IV = Buffer.from('mock-iv-1234').toString('base64'); +const MOCK_ENC_KEY = Buffer.from('mock-encrypted-data-key').toString('base64'); +const enc = (plaintext) => + `${MOCK_KEY_ID}:${MOCK_IV}:${Buffer.from(plaintext).toString( + 'base64' + )}:${MOCK_ENC_KEY}`; + describe('DocumentDBEncryptionService', () => { let service; let mockCryptor; @@ -16,27 +29,36 @@ describe('DocumentDBEncryptionService', () => { encrypt: jest.fn(async (val) => { const stringVal = typeof val === 'string' ? val : JSON.stringify(val); - return `encrypted:${stringVal}`; + return enc(stringVal); }), decrypt: jest.fn(async (val) => { - if (!val.startsWith('encrypted:')) { + const parts = val.split(':'); + if (parts.length !== 4 || parts[0] !== MOCK_KEY_ID) { throw new Error('Invalid encrypted format'); } - return val.replace('encrypted:', ''); + return Buffer.from(parts[2], 'base64').toString(); }), }; + // User.username is not a core field; apps opt in via + // appDefinition.encryption.schema, which these tests rely on. + registerCustomSchema({ User: { fields: ['username'] } }); + // Create service with mock cryptor service = new DocumentDBEncryptionService({ cryptor: mockCryptor }); }); + afterEach(() => { + resetCustomSchema(); + }); + describe('encryptFields', () => { it('encrypts User.username (custom field)', async () => { const doc = { username: 'test@example.com', type: 'INDIVIDUAL' }; const encrypted = await service.encryptFields('User', doc); - expect(encrypted.username).toBe('encrypted:test@example.com'); + expect(encrypted.username).toBe(enc('test@example.com')); expect(encrypted.type).toBe('INDIVIDUAL'); // Non-encrypted field unchanged }); @@ -45,7 +67,7 @@ describe('DocumentDBEncryptionService', () => { const encrypted = await service.encryptFields('User', doc); - expect(encrypted.hashword).toBe('encrypted:hashed_password'); + expect(encrypted.hashword).toBe(enc('hashed_password')); expect(encrypted.type).toBe('INDIVIDUAL'); }); @@ -58,8 +80,8 @@ describe('DocumentDBEncryptionService', () => { const encrypted = await service.encryptFields('User', doc); - expect(encrypted.username).toBe('encrypted:test@example.com'); - expect(encrypted.hashword).toBe('encrypted:hashed'); + expect(encrypted.username).toBe(enc('test@example.com')); + expect(encrypted.hashword).toBe(enc('hashed')); expect(encrypted.type).toBe('INDIVIDUAL'); }); @@ -96,10 +118,8 @@ describe('DocumentDBEncryptionService', () => { const encrypted = await service.encryptFields('Credential', doc); - expect(encrypted.data.access_token).toBe('encrypted:secret_token'); - expect(encrypted.data.refresh_token).toBe( - 'encrypted:refresh_secret' - ); + expect(encrypted.data.access_token).toBe(enc('secret_token')); + expect(encrypted.data.refresh_token).toBe(enc('refresh_secret')); expect(encrypted.data.other_field).toBe('not_encrypted'); expect(encrypted.userId).toBe('12345'); }); @@ -140,7 +160,7 @@ describe('DocumentDBEncryptionService', () => { 'YWVzLWtleS0x:TXlJVkhlcmU=:QWN0dWFsQ2lwaGVy:RW5jcnlwdGVk' ); // Plain field should be encrypted - expect(encrypted.hashword).toBe('encrypted:plain_text'); + expect(encrypted.hashword).toBe(enc('plain_text')); }); }); @@ -166,7 +186,7 @@ describe('DocumentDBEncryptionService', () => { describe('decryptFields', () => { it('decrypts User.username (custom field)', async () => { const doc = { - username: 'encrypted:test@example.com', + username: enc('test@example.com'), type: 'INDIVIDUAL', }; @@ -177,7 +197,7 @@ describe('DocumentDBEncryptionService', () => { }); it('decrypts User.hashword (core field)', async () => { - const doc = { hashword: 'encrypted:hashed_password' }; + const doc = { hashword: enc('hashed_password') }; const decrypted = await service.decryptFields('User', doc); @@ -201,8 +221,8 @@ describe('DocumentDBEncryptionService', () => { const doc = { userId: '12345', data: { - access_token: 'encrypted:secret_token', - refresh_token: 'encrypted:refresh_token', + access_token: enc('secret_token'), + refresh_token: enc('refresh_token'), }, }; @@ -213,7 +233,7 @@ describe('DocumentDBEncryptionService', () => { }); it('does not mutate original document', async () => { - const doc = { username: 'encrypted:test@example.com' }; + const doc = { username: enc('test@example.com') }; const originalUsername = doc.username; await service.decryptFields('User', doc); @@ -233,17 +253,17 @@ describe('DocumentDBEncryptionService', () => { disabledService.enabled = false; disabledService.cryptor = mockCryptor; - const doc = { username: 'encrypted:test@example.com' }; + const doc = { username: enc('test@example.com') }; const result = await disabledService.decryptFields('User', doc); - expect(result.username).toBe('encrypted:test@example.com'); // Not decrypted + expect(result.username).toBe(enc('test@example.com')); // Not decrypted expect(mockCryptor.decrypt).not.toHaveBeenCalled(); }); it('skips non-encrypted values', async () => { const doc = { username: 'plain_text', // Not in encrypted format - hashword: 'encrypted:hashed', + hashword: enc('hashed'), }; const decrypted = await service.decryptFields('User', doc); @@ -314,7 +334,7 @@ describe('DocumentDBEncryptionService', () => { const encrypted = await service.encryptFields('User', doc); - expect(encrypted.username).toBe('encrypted:test@example.com'); + expect(encrypted.username).toBe(enc('test@example.com')); expect(encrypted.createdAt).toEqual(date); // Date preserved }); @@ -345,7 +365,7 @@ describe('DocumentDBEncryptionService', () => { const encrypted = await service.encryptFields('User', doc); - expect(encrypted.username).toBe('encrypted:test@example.com'); + expect(encrypted.username).toBe(enc('test@example.com')); expect(encrypted.tags).toEqual(['tag1', 'tag2']); // Array preserved }); }); @@ -368,7 +388,7 @@ describe('DocumentDBEncryptionService', () => { new Error('Decryption failed') ); - const doc = { username: 'encrypted:test@example.com' }; + const doc = { username: enc('test@example.com') }; await expect(service.decryptFields('User', doc)).rejects.toThrow( 'Decryption failed' From c27dd704653d8cb03a80714a4f8793303541cfc8 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:13:19 -0400 Subject: [PATCH 07/44] test(core): fix broken mocks in the DocumentDB mapping encryption test Three assertions tested the mocks rather than the repository: - "stores encrypted mapping": the find mock always returned a document, so upsertMapping took the update path and never inserted. The first lookup now returns no match. - "preserves other fields during update": read `command.update.$set`, but `update` is the collection name; the update document is `command.updates[0].u` (see documentdb-utils updateOne). - "corrupted ciphertext": 'keyId:invalid-iv:...' isn't base64, so the service correctly treats it as legacy plaintext and returns it. The test now corrupts a real envelope (unknown key id) and expects the throw. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- ...g-repository-documentdb-encryption.test.js | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/core/integrations/repositories/__tests__/integration-mapping-repository-documentdb-encryption.test.js b/packages/core/integrations/repositories/__tests__/integration-mapping-repository-documentdb-encryption.test.js index 7055be551..2d1e6074a 100644 --- a/packages/core/integrations/repositories/__tests__/integration-mapping-repository-documentdb-encryption.test.js +++ b/packages/core/integrations/repositories/__tests__/integration-mapping-repository-documentdb-encryption.test.js @@ -130,6 +130,7 @@ describe('IntegrationMappingRepositoryDocumentDB - Encryption Integration', () = }); const insertedId = new ObjectId(); + let findCalls = 0; prisma.$runCommandRaw.mockImplementation((command) => { if (command.insert) { // Verify encrypted data goes to database @@ -137,6 +138,14 @@ describe('IntegrationMappingRepositoryDocumentDB - Encryption Integration', () = return Promise.resolve({ insertedId, n: 1, ok: 1 }); } if (command.find) { + findCalls += 1; + // First lookup: no existing mapping, so upsert inserts. + if (findCalls === 1) { + return Promise.resolve({ + cursor: { firstBatch: [] }, + ok: 1, + }); + } return Promise.resolve({ cursor: { firstBatch: [ @@ -289,10 +298,11 @@ describe('IntegrationMappingRepositoryDocumentDB - Encryption Integration', () = }); } if (command.update) { + const { $set } = command.updates[0].u; // Verify createdAt is NOT in update - expect(command.update.$set.createdAt).toBeUndefined(); + expect($set.createdAt).toBeUndefined(); // Verify updatedAt IS in update - expect(command.update.$set.updatedAt).toBeDefined(); + expect($set.updatedAt).toBeDefined(); return Promise.resolve({ nModified: 1, n: 1, ok: 1 }); } }); @@ -797,7 +807,17 @@ describe('IntegrationMappingRepositoryDocumentDB - Real Encryption Integration', }); it('throws error when trying to decrypt corrupted ciphertext', async () => { - const corruptedCiphertext = 'keyId:invalid-iv:corrupted-cipher:bad-encKey'; + // A well-formed envelope (so the service attempts decryption) whose + // key id no longer resolves to a key. Malformed strings such as + // 'keyId:invalid-iv:...' aren't recognised as ciphertext at all and + // are passed through as legacy plaintext. + const { mapping: validCiphertext } = + await realEncryptionService.encryptFields('IntegrationMapping', { + mapping: { secret: 'value' }, + }); + const [, iv, cipherText, encryptedKey] = validCiphertext.split(':'); + const unknownKeyId = Buffer.from('rotated-out-key-id').toString('base64'); + const corruptedCiphertext = [unknownKeyId, iv, cipherText, encryptedKey].join(':'); await expect( realEncryptionService.decryptFields('IntegrationMapping', { From 67a8f4b21d078e2a4fd66558bec45ecc48f8df82 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:13:43 -0400 Subject: [PATCH 08/44] test(core): use 32-byte keys and a 4-part envelope in the Cryptor test aes-256-ctr needs a 32-byte key. The test's data key ('test-key-32-bytes-long-exactly') and AES_KEY ('test-aes-key-32-bytes-long-123') are 30 bytes, so createCipheriv threw "Invalid key length". The "key not found" case passed a 3-part string, so decrypt failed on the missing 4th part before it reached the key lookup. Both fixtures now match the real formats; Cryptor itself is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- packages/core/encrypt/Cryptor.test.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/core/encrypt/Cryptor.test.js b/packages/core/encrypt/Cryptor.test.js index 8fa5c11ac..2f85c6c07 100644 --- a/packages/core/encrypt/Cryptor.test.js +++ b/packages/core/encrypt/Cryptor.test.js @@ -74,7 +74,7 @@ describe('Cryptor - AWS SDK v3', () => { const cryptor = new Cryptor({ shouldUseAws: true }); // First encrypt some data - const mockDataKey = Buffer.from('test-key-32-bytes-long-exactly'); + const mockDataKey = Buffer.from('test-data-key-is-32-bytes-long!!'); kmsMock.on(GenerateDataKeyCommand).resolves({ KeyId: 'test-key-id', Plaintext: mockDataKey, @@ -108,7 +108,7 @@ describe('Cryptor - AWS SDK v3', () => { describe('Local Mode (shouldUseAws: false)', () => { beforeEach(() => { - process.env.AES_KEY = 'test-aes-key-32-bytes-long-123'; + process.env.AES_KEY = 'test-aes-key-that-is-32-bytes!!!'; process.env.AES_KEY_ID = 'local-key-id'; }); @@ -135,7 +135,12 @@ describe('Cryptor - AWS SDK v3', () => { delete process.env.AES_KEY_ID; const cryptor = new Cryptor({ shouldUseAws: false }); - const fakeEncrypted = 'unknown-key:data:key'; + const fakeEncrypted = [ + Buffer.from('unknown-key').toString('base64'), + 'iv', + 'data', + Buffer.from('key').toString('base64'), + ].join(':'); await expect(cryptor.decrypt(fakeEncrypted)).rejects.toThrow('Encryption key not found'); }); From 24b05577ca8710b42fcc92dfdda5ac619eee3a32 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:14:48 -0400 Subject: [PATCH 09/44] test(core): assert Boom status codes, not default messages, in auth tests The x-frigg-headers and adopter-JWT use cases throw Boom.badRequest() / Boom.notImplemented(). The tests expected the *default* message ("Bad Request" / "Not Implemented") as a substring, which only holds when no message is given, so they failed on every Node version. They now assert output.statusCode (400 / 501), which is what the tests are named for; the message-content tests already exist alongside. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- .../tests/use-cases/get-user-from-adopter-jwt.test.js | 11 +++++------ .../use-cases/get-user-from-x-frigg-headers.test.js | 5 ++--- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/core/user/tests/use-cases/get-user-from-adopter-jwt.test.js b/packages/core/user/tests/use-cases/get-user-from-adopter-jwt.test.js index 2b08d13f6..528eeedc0 100644 --- a/packages/core/user/tests/use-cases/get-user-from-adopter-jwt.test.js +++ b/packages/core/user/tests/use-cases/get-user-from-adopter-jwt.test.js @@ -1,4 +1,3 @@ -const Boom = require('@hapi/boom'); const { GetUserFromAdopterJwt } = require('../../use-cases/get-user-from-adopter-jwt'); describe('GetUserFromAdopterJwt', () => { @@ -42,7 +41,7 @@ describe('GetUserFromAdopterJwt', () => { await expect( getUserFromAdopterJwt.execute(jwtToken) - ).rejects.toThrow(Boom.notImplemented().message); + ).rejects.toMatchObject({ output: { statusCode: 501 } }); }); it('should provide helpful error message about alternative auth modes', async () => { @@ -61,14 +60,14 @@ describe('GetUserFromAdopterJwt', () => { it('should throw 501 error with any token format', async () => { await expect( getUserFromAdopterJwt.execute('simple-token') - ).rejects.toThrow(Boom.notImplemented().message); + ).rejects.toMatchObject({ output: { statusCode: 501 } }); await expect( getUserFromAdopterJwt.execute('part1.part2.part3') - ).rejects.toThrow(Boom.notImplemented().message); + ).rejects.toMatchObject({ output: { statusCode: 501 } }); - await expect(getUserFromAdopterJwt.execute('')).rejects.toThrow( - Boom.notImplemented().message + await expect(getUserFromAdopterJwt.execute('')).rejects.toMatchObject( + { output: { statusCode: 501 } } ); }); }); diff --git a/packages/core/user/tests/use-cases/get-user-from-x-frigg-headers.test.js b/packages/core/user/tests/use-cases/get-user-from-x-frigg-headers.test.js index 64cbac0b0..36cafbf82 100644 --- a/packages/core/user/tests/use-cases/get-user-from-x-frigg-headers.test.js +++ b/packages/core/user/tests/use-cases/get-user-from-x-frigg-headers.test.js @@ -1,4 +1,3 @@ -const Boom = require('@hapi/boom'); const { GetUserFromXFriggHeaders } = require('../../use-cases/get-user-from-x-frigg-headers'); const { User } = require('../../user'); @@ -32,7 +31,7 @@ describe('GetUserFromXFriggHeaders', () => { it('should throw 400 error when neither appUserId nor appOrgId provided', async () => { await expect( getUserFromXFriggHeaders.execute(null, null) - ).rejects.toThrow(Boom.badRequest().message); + ).rejects.toMatchObject({ output: { statusCode: 400 } }); await expect( getUserFromXFriggHeaders.execute(undefined, undefined) @@ -274,7 +273,7 @@ describe('GetUserFromXFriggHeaders', () => { await expect( getUserFromXFriggHeaders.execute('app-user-456', 'app-org-789') - ).rejects.toThrow(Boom.badRequest().message); + ).rejects.toMatchObject({ output: { statusCode: 400 } }); await expect( getUserFromXFriggHeaders.execute('app-user-456', 'app-org-789') From 162ee927e15993097c5f02b9b83f81e485ab1d4c Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:15:40 -0400 Subject: [PATCH 10/44] test(core): update find-integration-context tests to the { externalId, type } API 1829e7dc renamed externalEntityId to externalId and 29890e55 made findIntegrationContextByExternalEntityId take { externalId, type } and pick the integration by config.type (a7bacab2) via the entity binding (326f2a7a), dropping the ENTITY_USER_NOT_FOUND path. The use-case and command tests still passed { externalEntityId } and expected the old error codes. They now cover EXTERNAL_ID_REQUIRED, TYPE_REQUIRED, ENTITY_NOT_FOUND, INTEGRATION_NOT_FOUND (no integration of that type on the entity) and the success path. The commands README examples were still positional and are updated to match. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- packages/core/application/commands/README.md | 28 ++++++++----- .../commands/integration-commands.test.js | 23 ++++++---- ...tion-context-by-external-entity-id.test.js | 42 ++++++++++--------- 3 files changed, 54 insertions(+), 39 deletions(-) diff --git a/packages/core/application/commands/README.md b/packages/core/application/commands/README.md index cb1015536..0c7f95c87 100644 --- a/packages/core/application/commands/README.md +++ b/packages/core/application/commands/README.md @@ -67,9 +67,10 @@ class MyIntegration extends IntegrationBase { async hydrateFromExternalUser(externalUserId) { // Find integration context by external entity ID const result = - await this.commands.findIntegrationContextByExternalEntityId( - externalUserId - ); + await this.commands.findIntegrationContextByExternalEntityId({ + externalId: externalUserId, + type: 'my-integration', // integration config.type to match + }); if (result.error) { return { error: result.error }; @@ -231,9 +232,10 @@ Manage integration records and load full integration contexts. ```javascript // Find integration context by external entity ID // Returns { context, error } where context includes record + hydrated modules -const result = await commands.findIntegrationContextByExternalEntityId( - 'external-user-or-workspace-id' -); +const result = await commands.findIntegrationContextByExternalEntityId({ + externalId: 'external-user-or-workspace-id', + type: 'my-integration', // integration config.type to match +}); if (!result.error) { integration.setIntegrationRecord(result.context); @@ -299,7 +301,10 @@ try { For integration context operations, errors are returned in the result: ```javascript -const result = await commands.findIntegrationContextByExternalEntityId(userId); +const result = await commands.findIntegrationContextByExternalEntityId({ + externalId: userId, + type: 'my-integration', +}); if (result.error) { return { error: result.error }; @@ -349,9 +354,10 @@ const result = const { createFriggCommands } = require('@friggframework/core'); this.commands = createFriggCommands({ integrationClass: MyIntegration }); -const result = await this.commands.findIntegrationContextByExternalEntityId( - userId -); +const result = await this.commands.findIntegrationContextByExternalEntityId({ + externalId: userId, + type: 'my-integration', +}); ``` ## Best Practices @@ -433,7 +439,7 @@ if (!user) { | | `findEntitiesByIds(ids)` | Find multiple by IDs | | | `updateEntity(id, updates)` | Update entity properties | | | `deleteEntity(id)` | Delete entity | -| **Integration** | `findIntegrationContextByExternalEntityId(externalId)` | Load integration + modules by external ID | +| **Integration** | `findIntegrationContextByExternalEntityId({ externalId, type })` | Load integration + modules by external ID | | | `loadIntegrationContextById(integrationId)` | Load integration + modules by ID | ## Support diff --git a/packages/core/application/commands/integration-commands.test.js b/packages/core/application/commands/integration-commands.test.js index 8a9833808..c57718de5 100644 --- a/packages/core/application/commands/integration-commands.test.js +++ b/packages/core/application/commands/integration-commands.test.js @@ -109,12 +109,14 @@ describe('integration commands', () => { integrationClass: DummyIntegration, }); - const result = await commands.findIntegrationContextByExternalEntityId( - 'ext-1', - ); + const result = await commands.findIntegrationContextByExternalEntityId({ + externalId: 'ext-1', + type: 'dummy', + }); expect(mockFindExecute).toHaveBeenCalledWith({ - externalEntityId: 'ext-1', + externalId: 'ext-1', + type: 'dummy', }); expect(result).toEqual({ context: expectedContext }); }); @@ -128,9 +130,10 @@ describe('integration commands', () => { integrationClass: DummyIntegration, }); - const result = await commands.findIntegrationContextByExternalEntityId( - 'ext-1', - ); + const result = await commands.findIntegrationContextByExternalEntityId({ + externalId: 'ext-1', + type: 'dummy', + }); expect(result).toEqual({ error: 401, @@ -161,11 +164,13 @@ describe('integration commands', () => { const result = await findIntegrationContextByExternalEntityId({ integrationClass: DummyIntegration, - externalEntityId: 'ext-2', + externalId: 'ext-2', + type: 'dummy', }); expect(mockFindExecute).toHaveBeenCalledWith({ - externalEntityId: 'ext-2', + externalId: 'ext-2', + type: 'dummy', }); expect(result).toEqual({ context: expectedContext }); }); diff --git a/packages/core/integrations/tests/use-cases/find-integration-context-by-external-entity-id.test.js b/packages/core/integrations/tests/use-cases/find-integration-context-by-external-entity-id.test.js index 400ad230a..3219bde06 100644 --- a/packages/core/integrations/tests/use-cases/find-integration-context-by-external-entity-id.test.js +++ b/packages/core/integrations/tests/use-cases/find-integration-context-by-external-entity-id.test.js @@ -22,39 +22,40 @@ describe('FindIntegrationContextByExternalEntityIdUseCase', () => { }); }); - it('throws when externalEntityId is missing', async () => { - await expect(useCase.execute({})).rejects.toHaveProperty( + it('throws when externalId is missing', async () => { + await expect(useCase.execute({ type: 'dummy' })).rejects.toHaveProperty( 'code', - 'EXTERNAL_ENTITY_ID_REQUIRED', + 'EXTERNAL_ID_REQUIRED', ); }); - it('throws when entity is not found', async () => { + it('throws when type is missing', async () => { await expect( - useCase.execute({ externalEntityId: 'abc' }), - ).rejects.toHaveProperty('code', 'ENTITY_NOT_FOUND'); + useCase.execute({ externalId: 'ext-1' }), + ).rejects.toHaveProperty('code', 'TYPE_REQUIRED'); }); - it('throws when entity user is missing', async () => { - moduleRepository.addEntity({ - id: 'entity-1', - externalId: 'ext-1', - }); - + it('throws when entity is not found', async () => { await expect( - useCase.execute({ externalEntityId: 'ext-1' }), - ).rejects.toHaveProperty('code', 'ENTITY_USER_NOT_FOUND'); + useCase.execute({ externalId: 'abc', type: 'dummy' }), + ).rejects.toHaveProperty('code', 'ENTITY_NOT_FOUND'); }); - it('throws when integration is not found for user', async () => { - moduleRepository.addEntity({ + it('throws when no integration of that type is bound to the entity', async () => { + const entity = { id: 'entity-1', externalId: 'ext-1', userId: 'user-1', - }); + }; + moduleRepository.addEntity(entity); + await integrationRepository.createIntegration( + [entity.id], + entity.userId, + { type: 'other' }, + ); await expect( - useCase.execute({ externalEntityId: 'ext-1' }), + useCase.execute({ externalId: 'ext-1', type: 'dummy' }), ).rejects.toHaveProperty('code', 'INTEGRATION_NOT_FOUND'); }); @@ -80,7 +81,10 @@ describe('FindIntegrationContextByExternalEntityIdUseCase', () => { expectedContext, ); - const result = await useCase.execute({ externalEntityId: 'ext-1' }); + const result = await useCase.execute({ + externalId: 'ext-1', + type: 'dummy', + }); expect(loadIntegrationContextUseCase.execute).toHaveBeenCalledWith({ integrationRecord, From cd616a52e4d22eacb0598f7c40631e453ea9728b Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:16:08 -0400 Subject: [PATCH 11/44] fix(core): give each SQS batch its own Entries array in batchSend batchSend reused one buffer for every SendMessageBatchCommand and cleared it in place with splice after each send. Every command therefore pointed at the same array, so after the loop all earlier commands' Entries held the final partial batch. The request had already gone out, so delivery was correct, but anything holding the command (SDK middleware, retries, the test's aws-sdk-client-mock recorder) saw the wrong entries; the "10 + 10 + 5" test failed with 5/5/5. Each full batch is now spliced out into its own array. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- packages/core/queues/queuer-util.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/core/queues/queuer-util.js b/packages/core/queues/queuer-util.js index 0c9f502ca..624fefad4 100644 --- a/packages/core/queues/queuer-util.js +++ b/packages/core/queues/queuer-util.js @@ -97,16 +97,17 @@ const QueuerUtil = { Id: uuid(), MessageBody: JSON.stringify(entry), }); - // Sends 10, then purges the buffer + // Sends 10, then starts a fresh buffer. Each command gets its own + // array: clearing the shared buffer in place would also empty the + // Entries of a command that was already sent. if (buffer.length === batchSize) { + const batch = buffer.splice(0, buffer.length); const command = new SendMessageBatchCommand({ - Entries: buffer, + Entries: batch, QueueUrl: queueUrl, }); const result = await sqs.send(command); - inspectBatchResult(result, queueUrl, buffer); - // Purge the buffer - buffer.splice(0, buffer.length); + inspectBatchResult(result, queueUrl, batch); } } From faea3e209b169acfb661b1be2f6a0d48aa4458a5 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:16:09 -0400 Subject: [PATCH 12/44] test(core): expect documentdb in the dbType validation message 2ef107f3 (feat(database): add DocumentDB support) made documentdb a valid dbType and the message "dbType must be postgresql, mongodb, or documentdb"; the test still matched the two-database wording. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- .../database/use-cases/check-database-state-use-case.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/database/use-cases/check-database-state-use-case.test.js b/packages/core/database/use-cases/check-database-state-use-case.test.js index e8a54f590..4b9894a5b 100644 --- a/packages/core/database/use-cases/check-database-state-use-case.test.js +++ b/packages/core/database/use-cases/check-database-state-use-case.test.js @@ -114,7 +114,7 @@ describe('CheckDatabaseStateUseCase', () => { await expect( useCase.execute('invalid-db', 'prod') - ).rejects.toThrow('dbType must be postgresql or mongodb'); + ).rejects.toThrow('dbType must be postgresql, mongodb, or documentdb'); }); it('should throw ValidationError for missing dbType', async () => { From dd927931f9b3eeddd45bc3c4fae269b3e6582f3c Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:16:44 -0400 Subject: [PATCH 13/44] test(core): make the health-check ping timing tests deterministic The postgres and mongodb "should measure actual response time" tests slept 30/50 ms with setTimeout and asserted the measured time was >= the delay. Timers can fire a millisecond early relative to Date.now() (seen locally: 29 ms), and loaded runners can blow the upper bound. The tests now stub Date.now() around an immediately-resolving ping and assert the exact difference, which is what pingDatabase computes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- .../health-check-repository-mongodb.test.js | 20 ++++++++++++------- .../health-check-repository-postgres.test.js | 20 ++++++++++++------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/packages/core/database/repositories/health-check-repository-mongodb.test.js b/packages/core/database/repositories/health-check-repository-mongodb.test.js index 2ee3f33fe..08123b82c 100644 --- a/packages/core/database/repositories/health-check-repository-mongodb.test.js +++ b/packages/core/database/repositories/health-check-repository-mongodb.test.js @@ -88,14 +88,20 @@ describe('HealthCheckRepositoryMongoDB', () => { }); it('should measure actual response time', async () => { - mockPrismaClient.$runCommandRaw.mockImplementation(() => - new Promise(resolve => setTimeout(() => resolve({ ok: 1 }), 50)) - ); - - const responseTime = await repository.pingDatabase(2000); + // Control the clock instead of sleeping (timer-based assertions + // are flaky on loaded CI runners). + const nowSpy = jest + .spyOn(Date, 'now') + .mockReturnValueOnce(1_000) + .mockReturnValueOnce(1_050); + mockPrismaClient.$runCommandRaw.mockResolvedValue({ ok: 1 }); - expect(responseTime).toBeGreaterThanOrEqual(50); - expect(responseTime).toBeLessThan(200); + try { + const responseTime = await repository.pingDatabase(2000); + expect(responseTime).toBe(50); + } finally { + nowSpy.mockRestore(); + } }); it('should reject with timeout error when ping exceeds maxTimeMS', async () => { diff --git a/packages/core/database/repositories/health-check-repository-postgres.test.js b/packages/core/database/repositories/health-check-repository-postgres.test.js index 186ab91b5..c75d3bf86 100644 --- a/packages/core/database/repositories/health-check-repository-postgres.test.js +++ b/packages/core/database/repositories/health-check-repository-postgres.test.js @@ -81,14 +81,20 @@ describe('HealthCheckRepositoryPostgreSQL', () => { }); it('should measure actual response time', async () => { - mockPrismaClient.$queryRaw.mockImplementation(() => - new Promise(resolve => setTimeout(() => resolve([{ '?column?': 1 }]), 30)) - ); - - const responseTime = await repository.pingDatabase(2000); + // Control the clock instead of sleeping: a 30 ms setTimeout can + // measure as 29 ms with Date.now(), which made this flaky. + const nowSpy = jest + .spyOn(Date, 'now') + .mockReturnValueOnce(1_000) + .mockReturnValueOnce(1_030); + mockPrismaClient.$queryRaw.mockResolvedValue([{ '?column?': 1 }]); - expect(responseTime).toBeGreaterThanOrEqual(30); - expect(responseTime).toBeLessThan(150); // Allow some buffer + try { + const responseTime = await repository.pingDatabase(2000); + expect(responseTime).toBe(30); + } finally { + nowSpy.mockRestore(); + } }); }); }); From a018ef56e420272148b80f1b56855307efd49631 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:17:43 -0400 Subject: [PATCH 14/44] test(core): port the webhook flow test to the AWS SDK v3 SQS client QueuerUtil moved to @aws-sdk/client-sqs, but this test still mocked `aws-sdk` (v2) SQS, so every queueing step made a real SQSClient call and failed with "Could not load credentials from any providers". It now uses aws-sdk-client-mock on SQSClient (as queuer-util.test.js does) and reads the SendMessageCommand inputs. Two expectations were also stale: - The default ON_WEBHOOK handler no longer logs "Webhook received:" (debug logging was removed in b74f21e0); it's a documented no-op, so the test asserts dispatch resolves. - The fixture's onWebhookReceived passed `req.params.integrationId` through as undefined; it now normalises to null like the base handler, which is what "webhooks without integration ID" checks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- .../handlers/webhook-flow.integration.test.js | 67 ++++++++----------- 1 file changed, 27 insertions(+), 40 deletions(-) diff --git a/packages/core/handlers/webhook-flow.integration.test.js b/packages/core/handlers/webhook-flow.integration.test.js index b479e5c19..3b9fa1b0c 100644 --- a/packages/core/handlers/webhook-flow.integration.test.js +++ b/packages/core/handlers/webhook-flow.integration.test.js @@ -8,18 +8,12 @@ const { IntegrationBase } = require('../integrations/integration-base'); const { IntegrationEventDispatcher } = require('./integration-event-dispatcher'); const { QueuerUtil } = require('../queues'); -// Mock AWS SQS -jest.mock('aws-sdk', () => { - const mockSQS = { - sendMessage: jest.fn((params, callback) => { - callback(null, { MessageId: 'mock-message-id-123' }); - }), - }; - return { - SQS: jest.fn(() => mockSQS), - config: { update: jest.fn() }, - }; -}); +// Mock AWS SQS (QueuerUtil uses the AWS SDK v3 SQSClient) +const { mockClient } = require('aws-sdk-client-mock'); +const { SQSClient, SendMessageCommand } = require('@aws-sdk/client-sqs'); +const sqsMock = mockClient(SQSClient); +const sentMessages = () => + sqsMock.commandCalls(SendMessageCommand).map((call) => call.args[0].input); class WebhookTestIntegration extends IntegrationBase { static Definition = { @@ -43,7 +37,8 @@ class WebhookTestIntegration extends IntegrationBase { } await this.queueWebhook({ - integrationId: req.params.integrationId, + // Same normalisation as IntegrationBase.onWebhookReceived + integrationId: req.params.integrationId || null, body: req.body, headers: req.headers, query: req.query, @@ -63,6 +58,10 @@ describe('Webhook Flow Integration Test', () => { describe('End-to-End Webhook Flow', () => { beforeEach(() => { jest.clearAllMocks(); + sqsMock.reset(); + sqsMock + .on(SendMessageCommand) + .resolves({ MessageId: 'mock-message-id-123' }); process.env.WEBHOOK_TEST_QUEUE_URL = 'https://sqs.us-east-1.amazonaws.com/123456789/test-queue'; }); @@ -95,11 +94,9 @@ describe('Webhook Flow Integration Test', () => { expect(res.json).toHaveBeenCalledWith({ received: true, verified: false }); // Verify message was queued - const AWS = require('aws-sdk'); - const mockSQS = new AWS.SQS(); - expect(mockSQS.sendMessage).toHaveBeenCalled(); + expect(sentMessages()).toHaveLength(1); - const queueCall = mockSQS.sendMessage.mock.calls[0][0]; + const queueCall = sentMessages()[0]; expect(queueCall.QueueUrl).toBe(process.env.WEBHOOK_TEST_QUEUE_URL); const queuedMessage = JSON.parse(queueCall.MessageBody); @@ -195,9 +192,7 @@ describe('Webhook Flow Integration Test', () => { }); // Should queue with integrationId: null - const AWS = require('aws-sdk'); - const mockSQS = new AWS.SQS(); - const queuedMessage = JSON.parse(mockSQS.sendMessage.mock.calls[0][0].MessageBody); + const queuedMessage = JSON.parse(sentMessages()[0].MessageBody); expect(queuedMessage.data.integrationId).toBeNull(); }); @@ -227,9 +222,7 @@ describe('Webhook Flow Integration Test', () => { next: jest.fn(), }); - const AWS = require('aws-sdk'); - const mockSQS = new AWS.SQS(); - const queuedMessage = JSON.parse(mockSQS.sendMessage.mock.calls[0][0].MessageBody); + const queuedMessage = JSON.parse(sentMessages()[0].MessageBody); expect(queuedMessage.data.headers).toEqual(req.headers); expect(queuedMessage.data.query).toEqual(req.query); @@ -277,8 +270,6 @@ describe('Webhook Flow Integration Test', () => { }); it('should use default ON_WEBHOOK handler if not overridden', async () => { - const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); - class DefaultWebhookIntegration extends IntegrationBase { static Definition = { name: 'default-webhook-worker', @@ -293,26 +284,22 @@ describe('Webhook Flow Integration Test', () => { const webhookData = { body: { test: 'data' } }; - await dispatcher.dispatchJob({ - event: 'ON_WEBHOOK', - data: webhookData, - context: {}, - }); - - // Default handler logs the data - expect(consoleSpy).toHaveBeenCalledWith('Webhook received:', webhookData); - - consoleSpy.mockRestore(); + // The base ON_WEBHOOK handler is a no-op that integrations + // override; dispatching to it must succeed without side effects. + await expect( + dispatcher.dispatchJob({ + event: 'ON_WEBHOOK', + data: webhookData, + context: {}, + }) + ).resolves.toBeUndefined(); }); }); describe('Error Handling', () => { it('should handle queueing errors gracefully', async () => { - const AWS = require('aws-sdk'); - const mockSQS = new AWS.SQS(); - mockSQS.sendMessage.mockImplementation((params, callback) => { - callback(new Error('Queue is full'), null); - }); + sqsMock.reset(); + sqsMock.on(SendMessageCommand).rejects(new Error('Queue is full')); process.env.WEBHOOK_TEST_QUEUE_URL = 'https://sqs.us-east-1.amazonaws.com/123456789/test-queue'; From 8567def14a5bbb019148ae43969c35ec4de83e39 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:18:22 -0400 Subject: [PATCH 15/44] test(core): keep Node runtime warnings out of the require-graph probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ADR-048 require-graph probe reports CLEAN/DIRTY on the child's stderr. On Node 22, requiring core's index emits a DEP0040 (punycode) deprecation warning on stderr too, so the assertion saw "CLEAN(node:…) [DEP0040] …" and failed on the 22.x leg only. The probe now runs the child with --no-warnings. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- packages/core/logs/require-graph.test.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core/logs/require-graph.test.js b/packages/core/logs/require-graph.test.js index fed74fda3..3b7b62cc7 100644 --- a/packages/core/logs/require-graph.test.js +++ b/packages/core/logs/require-graph.test.js @@ -9,7 +9,9 @@ const cacheReport = (pattern) => ` `; function runProbe(script, env = {}) { - const result = spawnSync(process.execPath, ['-e', script], { + // --no-warnings: the probe reports on stderr, and Node runtime warnings + // (e.g. DEP0040 punycode on Node 22) would otherwise be mixed into it. + const result = spawnSync(process.execPath, ['--no-warnings', '-e', script], { cwd: CORE_DIR, encoding: 'utf8', env: { PATH: process.env.PATH, ...env }, From 5850051dc2916ebc460c264b09bdec3349aa819f Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:20:09 -0400 Subject: [PATCH 16/44] test(test): smoke-test TestMongo under `npm run test:db` TestMongo is now opt-in (core no longer starts it), so nothing in the default run would notice if the pinned binary stopped downloading or starting. mongodb.db.test.js starts it and checks MONGO_URI accepts a connection. It follows the same convention as core: `*.db.test.js` is excluded from `npm test` and run by `npm run test:db` (the CI DB job). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- packages/test/mongodb.db.test.js | 24 ++++++++++++++++++++++++ packages/test/package.json | 3 ++- 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 packages/test/mongodb.db.test.js diff --git a/packages/test/mongodb.db.test.js b/packages/test/mongodb.db.test.js new file mode 100644 index 000000000..2d28c5e9e --- /dev/null +++ b/packages/test/mongodb.db.test.js @@ -0,0 +1,24 @@ +const net = require('node:net'); +const { TestMongo } = require('./mongodb'); + +// Opt-in DB test (`npm run test:db`): proves the pinned MongoDB binary +// downloads and starts on the CI image (Ubuntu 24.04 has no OpenSSL 1.1, +// which the old 5.0.x default needed). +describe('TestMongo', () => { + const testMongo = new TestMongo(); + + beforeAll(() => testMongo.start(), 120_000); + afterAll(() => testMongo.stop()); + + it('starts mongod and exports a reachable MONGO_URI', async () => { + const { hostname, port } = new URL(process.env.MONGO_URI); + + await new Promise((resolve, reject) => { + const socket = net.connect(Number(port), hostname, () => { + socket.end(); + resolve(); + }); + socket.on('error', reject); + }); + }); +}); diff --git a/packages/test/package.json b/packages/test/package.json index 943c3ce36..23c03d7d1 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -22,7 +22,8 @@ }, "scripts": { "lint:fix": "prettier --write --loglevel error . && eslint . --fix", - "test": "jest --passWithNoTests # TODO" + "test": "jest --passWithNoTests --testPathIgnorePatterns '\\.db\\.test\\.js$' # TODO", + "test:db": "jest --testMatch '**/*.db.test.js'" }, "author": "", "license": "MIT", From 6b940d02d821c3536d8579d690a224ebfcacc1c8 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:20:57 -0400 Subject: [PATCH 17/44] ci: run Frigg CI on pushes to next `next` is where PRs land and pre-releases are cut from, but CI only ran on pushes to main, so next itself never had a status of its own. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- .github/workflows/frigg-ci.js.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/frigg-ci.js.yml b/.github/workflows/frigg-ci.js.yml index 2c4742cc7..05475b4da 100644 --- a/.github/workflows/frigg-ci.js.yml +++ b/.github/workflows/frigg-ci.js.yml @@ -5,7 +5,7 @@ name: Frigg CI on: push: - branches: [main] + branches: [main, next] paths-ignore: - docs/** pull_request: From 6428df711df9b5aaf8bb61530e4fe8feb0904773 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:20:57 -0400 Subject: [PATCH 18/44] ci: test on Node 22.x and 24.x 2.0.1 supports Node 22 and 24, so CI covers both. fail-fast is off so a failure on one line still reports the other. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- .github/workflows/frigg-ci.js.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/frigg-ci.js.yml b/.github/workflows/frigg-ci.js.yml index 05475b4da..91a96009d 100644 --- a/.github/workflows/frigg-ci.js.yml +++ b/.github/workflows/frigg-ci.js.yml @@ -16,8 +16,10 @@ jobs: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - node-version: [22.x] + # The Node lines 2.0.1 supports. + node-version: [22.x, 24.x] steps: - name: Check Out Code ⤵️ From 82ce5ea465194f2bbb616a93c2e432338785f88c Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:21:13 -0400 Subject: [PATCH 19/44] ci: shallow checkout, setup-node npm cache, cancel superseded runs - fetch-depth: 0 was there for super-linter, which this workflow no longer runs; the full fetch pulled thousands of release tags (~2k lines of log) on every run. Default shallow checkout instead. - The hand-rolled ~/.npm cache is what `actions/setup-node` `cache: npm` does, keyed on the lockfile. - `npm run use:npm` reinstalled npm globally to satisfy engines.npm ">=10"; Node 22 and 24 already ship npm >= 10. - A newer push to the same ref cancels the in-flight run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- .github/workflows/frigg-ci.js.yml | 25 ++++++------------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/.github/workflows/frigg-ci.js.yml b/.github/workflows/frigg-ci.js.yml index 91a96009d..ae474aeca 100644 --- a/.github/workflows/frigg-ci.js.yml +++ b/.github/workflows/frigg-ci.js.yml @@ -11,6 +11,11 @@ on: pull_request: branches: ["**"] +# A newer push to the same branch/PR supersedes the running check. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: build: runs-on: ubuntu-latest @@ -24,30 +29,12 @@ jobs: steps: - name: Check Out Code ⤵️ uses: actions/checkout@v4 - with: - # Full git history is needed to get a proper list of changed files within `super-linter` - fetch-depth: 0 - name: Use Node.js ${{ matrix.node-version }} 🛠 uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - - - name: Use correct NPM version 🛠 - run: npm run use:npm - - - name: Cache node modules 📦 - uses: actions/cache@v4 - env: - cache-name: cache-node-modules - with: - # npm cache files are stored in `~/.npm` on Linux/macOS - path: ~/.npm - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-build-${{ env.cache-name }}- - ${{ runner.os }}-build- - ${{ runner.os }}- + cache: npm - name: Install Dependencies 🛠 run: npm ci From bf001d1e9813091a67e4c4b43c90accfae2e2f9f Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:21:30 -0400 Subject: [PATCH 20/44] ci: replace lint auto-fix + auto-commit with a read-only lint check The build job ran `npm run lint:fix --workspaces` and then git-auto-commit-action pushed "Apply lint auto-fixes" to the branch. A bot must not push to PR branches (it rewrites contributors' branches, and its commits don't re-trigger CI, so they land untested), and it only ran after tests passed, which they never did. Lint is now its own job: `prettier --check` + `eslint` in each package that defines lint:fix, with no writes. The step is continue-on-error because the existing backlog is large (core alone: ~390 files need prettier, 96 eslint errors); the job shows the findings without blocking merges. Flip it to blocking once the backlog is cleared. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- .github/workflows/frigg-ci.js.yml | 37 ++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/.github/workflows/frigg-ci.js.yml b/.github/workflows/frigg-ci.js.yml index ae474aeca..11b52e0a4 100644 --- a/.github/workflows/frigg-ci.js.yml +++ b/.github/workflows/frigg-ci.js.yml @@ -48,11 +48,36 @@ jobs: run: npm run test:all timeout-minutes: 10 - # NOTE: Linter steps should run last. If the linter auto-fixes files and pushes the fixes, the result is that any errors that may occur in previous steps may be hidden in the pull request because tests will not be run on the auto-fix commits. - - - name: Linter 🔍 - run: npm run lint:fix --workspaces + lint: + # Read-only lint check: reports prettier/eslint problems, never rewrites + # files or pushes commits. Non-blocking for now because the tree has a + # large existing backlog (e.g. core: ~390 files need prettier, 96 eslint + # errors); make the step blocking once that backlog is cleared. + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check Out Code ⤵️ + uses: actions/checkout@v4 - - uses: stefanzweifel/git-auto-commit-action@v4 + - name: Use Node.js 22.x 🛠 + uses: actions/setup-node@v4 with: - commit_message: ✨ Apply lint auto-fixes \ No newline at end of file + node-version: 22.x + cache: npm + + - name: Install Dependencies 🛠 + run: npm ci + + - name: Lint (check only) 🔍 + continue-on-error: true + run: | + status=0 + for ws in packages/*/; do + # Packages that define lint:fix are the ones with a lint setup. + node -e "process.exit(require('./${ws}package.json').scripts?.['lint:fix'] ? 0 : 1)" || continue + echo "::group::${ws}" + (cd "$ws" && npx prettier --check --loglevel warn . && npx eslint .) || status=1 + echo "::endgroup::" + done + exit $status From a4a26dd7752f16a95401bcffc121d9907cd2fa12 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:21:57 -0400 Subject: [PATCH 21/44] ci: run the *.db.test.js suites in their own job with Mongo and Postgres Real-database suites are now `*.db.test.js`, excluded from test:all and run by `npm run test:db`. This job provides what they need: - MongoDB 8.0 started as a single-node replica set (Prisma requires one for MongoDB writes; service containers can't pass --replSet); - a postgres:16 service, with the prisma-postgresql migrations applied; - generated Prisma clients; - a cache for the mongodb-memory-server binary used by TestMongo's smoke test (MONGOMS_DOWNLOAD_DIR points it at the cached path). It runs in parallel with the unit matrix. The Postgres service is also what the tenant-isolation suite (FRIGG_TEST_POSTGRES_URL) needs when it lands on next. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- .github/workflows/frigg-ci.js.yml | 70 +++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/.github/workflows/frigg-ci.js.yml b/.github/workflows/frigg-ci.js.yml index 11b52e0a4..02641ef28 100644 --- a/.github/workflows/frigg-ci.js.yml +++ b/.github/workflows/frigg-ci.js.yml @@ -48,6 +48,76 @@ jobs: run: npm run test:all timeout-minutes: 10 + db: + # Opt-in database integration suites (`*.db.test.js`, `npm run test:db`). + # They are excluded from test:all so unit runs never need a database. + # The Postgres service also serves the tenant-isolation suite + # (FRIGG_TEST_POSTGRES_URL) once it lands on next. + runs-on: ubuntu-latest + permissions: + contents: read + services: + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: frigg + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + FRIGG_TEST_MONGODB_URL: mongodb://localhost:27017/frigg?replicaSet=rs0 + FRIGG_TEST_POSTGRES_URL: postgresql://postgres:postgres@localhost:5432/frigg?schema=public + MONGOMS_DOWNLOAD_DIR: ${{ github.workspace }}/.cache/mongodb-binaries + steps: + - name: Check Out Code ⤵️ + uses: actions/checkout@v4 + + # Prisma needs a replica set for MongoDB writes, and service containers + # can't take the --replSet argument, so start it directly. + - name: Start MongoDB replica set 🍃 + run: | + docker run -d --name mongo -p 27017:27017 mongo:8.0 --replSet rs0 --bind_ip_all + for i in $(seq 1 30); do + docker exec mongo mongosh --quiet --eval 'db.runCommand({ ping: 1 }).ok' && break + sleep 1 + done + docker exec mongo mongosh --quiet --eval 'rs.initiate({ _id: "rs0", members: [{ _id: 0, host: "localhost:27017" }] })' + for i in $(seq 1 30); do + [ "$(docker exec mongo mongosh --quiet --eval 'db.hello().isWritablePrimary')" = "true" ] && break + sleep 1 + done + + - name: Use Node.js 22.x 🛠 + uses: actions/setup-node@v4 + with: + node-version: 22.x + cache: npm + + - name: Install Dependencies 🛠 + run: npm ci + + - name: Cache mongodb-memory-server binaries 📦 + uses: actions/cache@v4 + with: + path: ${{ github.workspace }}/.cache/mongodb-binaries + key: mongodb-binaries-${{ runner.os }}-${{ hashFiles('packages/test/mongodb.js') }} + + - name: Prepare databases 🗄 + working-directory: packages/core + run: | + npm run prisma:generate + DATABASE_URL="$FRIGG_TEST_POSTGRES_URL" npx prisma migrate deploy --schema prisma-postgresql/schema.prisma + DATABASE_URL="$FRIGG_TEST_MONGODB_URL" npx prisma db push --skip-generate --schema prisma-mongodb/schema.prisma + + - name: DB tests 🧪 + run: npm run test:db --workspaces --if-present + timeout-minutes: 10 + lint: # Read-only lint check: reports prettier/eslint problems, never rewrites # files or pushes commits. Non-blocking for now because the tree has a From ecad26ad858599c40f1cacfb012c879f7db62762 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:22:25 -0400 Subject: [PATCH 22/44] ci: retire test-cli.yml, keep its CLI smoke check in Frigg CI test-cli.yml failed on every run: it cd's into packages/frigg-cli, which moved to packages/devtools/frigg-cli, and its matrix still tested Node 18/20/21, which 2.0.1 doesn't support. What it ran: - unit/integration tests and coverage: already run by devtools' jest in test:all (frigg-cli/__tests__), so dropped here; - test:e2e / test:unit / test:integration / test:coverage scripts: no longer exist in the CLI package; - `frigg --help` checks: kept, as a step in the build job, for every current command; - Windows/macOS legs: dropped. Nothing else in CI covers them and they never ran green; re-add a targeted OS matrix if cross-platform CLI support becomes a requirement; - Codecov upload with fail_ci_if_error and an 85% coverage gate: dropped (no token configured, gate never met). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- .github/workflows/frigg-ci.js.yml | 9 ++ .github/workflows/test-cli.yml | 203 ------------------------------ 2 files changed, 9 insertions(+), 203 deletions(-) delete mode 100644 .github/workflows/test-cli.yml diff --git a/.github/workflows/frigg-ci.js.yml b/.github/workflows/frigg-ci.js.yml index 02641ef28..571bc877d 100644 --- a/.github/workflows/frigg-ci.js.yml +++ b/.github/workflows/frigg-ci.js.yml @@ -48,6 +48,15 @@ jobs: run: npm run test:all timeout-minutes: 10 + # CLI smoke test (from the retired test-cli.yml): the `frigg` bin + # resolves and every command loads far enough to print its help. + - name: CLI smoke test 🧰 + run: | + npx frigg --help + for cmd in init install start build deploy generate-iam ui db:setup doctor repair ssm auth; do + npx frigg "$cmd" --help > /dev/null + done + db: # Opt-in database integration suites (`*.db.test.js`, `npm run test:db`). # They are excluded from test:all so unit runs never need a database. diff --git a/.github/workflows/test-cli.yml b/.github/workflows/test-cli.yml deleted file mode 100644 index 9d4a06cda..000000000 --- a/.github/workflows/test-cli.yml +++ /dev/null @@ -1,203 +0,0 @@ -name: CLI Test Suite - -on: - push: - branches: [ main, develop, create-frigg-app ] - paths: - - 'packages/frigg-cli/**' - - '.github/workflows/test-cli.yml' - pull_request: - branches: [ main, develop ] - paths: - - 'packages/frigg-cli/**' - - '.github/workflows/test-cli.yml' - -jobs: - test-cli: - name: Test CLI (${{ matrix.os }} - Node ${{ matrix.node-version }}) - runs-on: ${{ matrix.os }} - - strategy: - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - node-version: [18.x, 20.x, 21.x, 22.x] - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - cache: 'npm' - - - name: Install dependencies - run: | - cd packages/frigg-cli - npm ci - - - name: Run linting - run: | - cd packages/frigg-cli - npm run lint - - - name: Run unit tests - run: | - cd packages/frigg-cli - npm run test:unit - - - name: Run integration tests - run: | - cd packages/frigg-cli - npm run test:integration - - - name: Run coverage analysis - run: | - cd packages/frigg-cli - npm run test:coverage - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 - with: - file: ./packages/frigg-cli/coverage/lcov.info - flags: cli - name: cli-coverage - fail_ci_if_error: true - - - name: Upload test results - uses: actions/upload-artifact@v4 - if: always() - with: - name: test-results-${{ matrix.os }}-${{ matrix.node-version }} - path: | - packages/frigg-cli/coverage/ - packages/frigg-cli/coverage/junit.xml - - e2e-tests: - name: End-to-End CLI Tests - runs-on: ubuntu-latest - needs: test-cli - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '22.x' - cache: 'npm' - - - name: Install dependencies - run: | - cd packages/frigg-cli - npm ci - - - name: Build CLI package - run: | - cd packages/frigg-cli - npm pack - - - name: Test CLI installation - run: | - cd packages/frigg-cli - npm install -g friggframework-cli-*.tgz - - - name: Test CLI commands - run: | - frigg --help - frigg install --help - frigg build --help - frigg deploy --help - frigg generate --help - frigg ui --help - - - name: Run E2E tests - run: | - cd packages/frigg-cli - npm run test:e2e - - quality-gates: - name: Quality Gates - runs-on: ubuntu-latest - needs: [test-cli, e2e-tests] - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '22.x' - cache: 'npm' - - - name: Install dependencies - run: | - cd packages/frigg-cli - npm ci - - - name: Generate coverage report - run: | - cd packages/frigg-cli - npm run test:coverage - - - name: Check coverage thresholds - run: | - cd packages/frigg-cli - npx jest --config __tests__/jest.config.js --coverage --passWithNoTests --coverageThreshold='{"global":{"branches":85,"functions":85,"lines":85,"statements":85}}' - - - name: Security audit - run: | - cd packages/frigg-cli - npm audit --audit-level=moderate - - - name: Check for outdated dependencies - run: | - cd packages/frigg-cli - npm outdated || true - - performance-benchmarks: - name: Performance Benchmarks - runs-on: ubuntu-latest - needs: test-cli - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '22.x' - cache: 'npm' - - - name: Install dependencies - run: | - cd packages/frigg-cli - npm ci - - - name: Run performance tests - run: | - cd packages/frigg-cli - time node index.js --help - time node index.js install --help - time node index.js build --help - time node index.js deploy --help - time node index.js generate --help - time node index.js ui --help - - - name: Memory usage benchmark - run: | - cd packages/frigg-cli - node -e " - const { performance } = require('perf_hooks'); - const used = process.memoryUsage(); - console.log('Memory usage:'); - for (let key in used) { - console.log(\`\${key}: \${Math.round(used[key] / 1024 / 1024 * 100) / 100} MB\`); - } - " \ No newline at end of file From db74351e404bfc52b1428497cdff87057c4f70e4 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:23:54 -0400 Subject: [PATCH 23/44] test(core): run the repository parity suites in the DB job integration-repository-entities-parity and integration-mapping-repository-query-parity run every adapter (MongoDB, DocumentDB on MongoDB, PostgreSQL) through the same cases, but only when their *_PARITY_* URLs are set, which no CI job did; in test:all they showed up as 476 skipped tests. They are renamed to *.db.test.js (still env-gated) and the DB job sets their URLs, so they now run on every PR: 1,422 tests, green locally against mongo:8.0 (replica set) and postgres:16. These are the seed of per-port repository contract tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- .github/workflows/frigg-ci.js.yml | 6 ++++++ packages/core/CLAUDE.md | 4 ++-- ... integration-mapping-repository-query-parity.db.test.js} | 0 ...js => integration-repository-entities-parity.db.test.js} | 0 packages/core/jest.db.config.js | 2 ++ 5 files changed, 10 insertions(+), 2 deletions(-) rename packages/core/integrations/repositories/{integration-mapping-repository-query-parity.test.js => integration-mapping-repository-query-parity.db.test.js} (100%) rename packages/core/integrations/repositories/{integration-repository-entities-parity.test.js => integration-repository-entities-parity.db.test.js} (100%) diff --git a/.github/workflows/frigg-ci.js.yml b/.github/workflows/frigg-ci.js.yml index 571bc877d..266715be7 100644 --- a/.github/workflows/frigg-ci.js.yml +++ b/.github/workflows/frigg-ci.js.yml @@ -81,6 +81,12 @@ jobs: env: FRIGG_TEST_MONGODB_URL: mongodb://localhost:27017/frigg?replicaSet=rs0 FRIGG_TEST_POSTGRES_URL: postgresql://postgres:postgres@localhost:5432/frigg?schema=public + # Repository parity suites: every adapter (MongoDB, DocumentDB-on-Mongo, + # PostgreSQL) against the same cases. + INTEGRATION_ENTITIES_PARITY_MONGO_URL: mongodb://localhost:27017/frigg?replicaSet=rs0 + INTEGRATION_ENTITIES_PARITY_POSTGRES_URL: postgresql://postgres:postgres@localhost:5432/frigg?schema=public + QUERY_MAPPINGS_PARITY_MONGO_URL: mongodb://localhost:27017/frigg?replicaSet=rs0 + QUERY_MAPPINGS_PARITY_POSTGRES_URL: postgresql://postgres:postgres@localhost:5432/frigg?schema=public MONGOMS_DOWNLOAD_DIR: ${{ github.workspace }}/.cache/mongodb-binaries steps: - name: Check Out Code ⤵️ diff --git a/packages/core/CLAUDE.md b/packages/core/CLAUDE.md index 6ae90d5a2..eaa17a3c0 100644 --- a/packages/core/CLAUDE.md +++ b/packages/core/CLAUDE.md @@ -224,7 +224,7 @@ packages/core/ (`remove-entities-from-integration.js`) refuses to leave a module with no entity (`LAST_ENTITY_OF_MODULE`, 409). Re-adding an attached entity or removing a detached one changes nothing; a detached entity is not deleted. - `integration-repository-entities-parity.test.js` runs every adapter against + `integration-repository-entities-parity.db.test.js` runs every adapter against real databases when `INTEGRATION_ENTITIES_PARITY_MONGO_URL` / `INTEGRATION_ENTITIES_PARITY_POSTGRES_URL` are set. The DocumentDB adapter writes only `Integration.entityIds` (as its `createIntegration` does), not @@ -266,7 +266,7 @@ packages/core/ mapping keys to leave out of the rows; never write such rows back. Path segments must match `^[A-Za-z_][A-Za-z0-9_]*$`. The PostgreSQL, MongoDB and DocumentDB adapters give the same pages and totals; - `integration-mapping-repository-query-parity.test.js` checks that against + `integration-mapping-repository-query-parity.db.test.js` checks that against real databases when `QUERY_MAPPINGS_PARITY_MONGO_URL` / `QUERY_MAPPINGS_PARITY_POSTGRES_URL` are set. Two orderings still differ: strings compare by the database collation on PostgreSQL and by code point diff --git a/packages/core/integrations/repositories/integration-mapping-repository-query-parity.test.js b/packages/core/integrations/repositories/integration-mapping-repository-query-parity.db.test.js similarity index 100% rename from packages/core/integrations/repositories/integration-mapping-repository-query-parity.test.js rename to packages/core/integrations/repositories/integration-mapping-repository-query-parity.db.test.js diff --git a/packages/core/integrations/repositories/integration-repository-entities-parity.test.js b/packages/core/integrations/repositories/integration-repository-entities-parity.db.test.js similarity index 100% rename from packages/core/integrations/repositories/integration-repository-entities-parity.test.js rename to packages/core/integrations/repositories/integration-repository-entities-parity.db.test.js diff --git a/packages/core/jest.db.config.js b/packages/core/jest.db.config.js index 82d066d00..909e37aa7 100644 --- a/packages/core/jest.db.config.js +++ b/packages/core/jest.db.config.js @@ -10,6 +10,8 @@ * - PostgreSQL with the prisma-postgresql migrations applied: * FRIGG_TEST_POSTGRES_URL, default * postgresql://postgres:postgres@localhost:5432/frigg?schema=public + * The repository parity suites skip themselves unless their own URLs are + * set (INTEGRATION_ENTITIES_PARITY_* / QUERY_MAPPINGS_PARITY_*). */ const base = require('./jest.config'); From 22c1a77b55b29e5635fd3c331fe1613396073d38 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:30:40 -0400 Subject: [PATCH 24/44] fix(devtools): attach every user-supplied Lambda security group VpcBuilder's EXTERNAL security-group branch read decision.physicalId, which createExternalDecision sets to the first ID only (the full list is in physicalIds), so all but the first of vpc.securityGroupIds were silently dropped from the Lambda VpcConfig. Use physicalIds when present. Covered by the composer test's use-existing security-group case. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- .../infrastructure/domains/networking/vpc-builder.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/devtools/infrastructure/domains/networking/vpc-builder.js b/packages/devtools/infrastructure/domains/networking/vpc-builder.js index 2b9d07f14..55d7d7f3f 100644 --- a/packages/devtools/infrastructure/domains/networking/vpc-builder.js +++ b/packages/devtools/infrastructure/domains/networking/vpc-builder.js @@ -691,8 +691,11 @@ class VpcBuilder extends InfrastructureBuilder { result.vpcConfig.securityGroupIds = [{ Ref: 'FriggLambdaSecurityGroup' }]; console.log(' ✅ Security Group added to template'); } else if (decision.ownership === ResourceOwnership.EXTERNAL) { - // Use external security group IDs - const sgIds = Array.isArray(decision.physicalId) ? decision.physicalId : [decision.physicalId]; + // Use external security group IDs. createExternalDecision keeps the + // full list in physicalIds; physicalId is only the first of them. + const sgIds = decision.physicalIds?.length + ? decision.physicalIds + : Array.isArray(decision.physicalId) ? decision.physicalId : [decision.physicalId]; result.vpcConfig.securityGroupIds = sgIds; console.log(` ✓ Using external security group(s): ${sgIds.join(', ')}`); } From 54d8ae8b8e761048f377e25ff0341fddd8f519e0 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:30:40 -0400 Subject: [PATCH 25/44] chore(devtools): neutral wording in a VpcBuilder comment Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- .../devtools/infrastructure/domains/networking/vpc-builder.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/devtools/infrastructure/domains/networking/vpc-builder.js b/packages/devtools/infrastructure/domains/networking/vpc-builder.js index 55d7d7f3f..07f8a9363 100644 --- a/packages/devtools/infrastructure/domains/networking/vpc-builder.js +++ b/packages/devtools/infrastructure/domains/networking/vpc-builder.js @@ -1178,7 +1178,7 @@ class VpcBuilder extends InfrastructureBuilder { const vpcId = result.vpcId; // Determine logical IDs based on what exists in stack for backwards compatibility - // CRITICAL: Frontify production uses OLD naming (VPCEndpointS3, not FriggS3VPCEndpoint) + // CRITICAL: older production stacks use OLD naming (VPCEndpointS3, not FriggS3VPCEndpoint) const existingLogicalIds = discoveredResources?.existingLogicalIds || []; From 82ddf5c35e91ccc86f81dad096ff22edebeacc83 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:30:40 -0400 Subject: [PATCH 26/44] fix(devtools): really ignore legacy VPC options under managementMode='managed' warnIgnoredOptions() tells the user that vpc.management, subnets.management, natGateway.management and shareAcrossStages are ignored in managed mode, but translateLegacyConfig still applied them. With managed + isolated and no VPC in the stack, a leftover vpc.management flipped ownership back to 'auto' and the build failed with "VPC discovery failed: No VPC found". The four options are now applied only outside managed mode; natGateway.id handling is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- .../domains/networking/vpc-builder.js | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/devtools/infrastructure/domains/networking/vpc-builder.js b/packages/devtools/infrastructure/domains/networking/vpc-builder.js index 07f8a9363..c6946f554 100644 --- a/packages/devtools/infrastructure/domains/networking/vpc-builder.js +++ b/packages/devtools/infrastructure/domains/networking/vpc-builder.js @@ -439,8 +439,14 @@ class VpcBuilder extends InfrastructureBuilder { translated.vpc.ownership.subnets = 'external'; } + // Legacy options that warnIgnoredOptions() reports as ignored under + // managementMode='managed' must really be ignored: applying them would + // undo the ownership chosen above (e.g. vpc.management='discover' turning + // managed+isolated's ownership.vpc='stack' back into 'auto'). + const honorLegacyModes = globalMode !== 'managed'; + // Handle legacy vpc.management modes - const vpcManagement = appDefinition.vpc?.management; + const vpcManagement = honorLegacyModes ? appDefinition.vpc?.management : undefined; if (vpcManagement === 'create-new') { translated.vpc.ownership.vpc = 'stack'; translated.vpc.ownership.securityGroup = 'stack'; @@ -466,7 +472,7 @@ class VpcBuilder extends InfrastructureBuilder { } // Handle legacy shareAcrossStages - if (appDefinition.vpc?.shareAcrossStages !== undefined) { + if (honorLegacyModes && appDefinition.vpc?.shareAcrossStages !== undefined) { if (appDefinition.vpc.shareAcrossStages) { // Shared VPC - discover and reuse translated.vpc.ownership.vpc = 'auto'; @@ -481,7 +487,7 @@ class VpcBuilder extends InfrastructureBuilder { } // Handle legacy NAT Gateway management - if (appDefinition.vpc?.natGateway?.management === 'createAndManage') { + if (honorLegacyModes && appDefinition.vpc?.natGateway?.management === 'createAndManage') { // Use 'auto' to allow discovering and reusing properly placed external NAT Gateways // The resolver will check if there's a good external NAT Gateway and reuse it, // or create a new one if needed (or if the existing one is misplaced) @@ -493,9 +499,10 @@ class VpcBuilder extends InfrastructureBuilder { } // Handle legacy subnet management - if (appDefinition.vpc?.subnets?.management === 'create') { + const subnetManagement = honorLegacyModes ? appDefinition.vpc?.subnets?.management : undefined; + if (subnetManagement === 'create') { translated.vpc.ownership.subnets = 'stack'; - } else if (appDefinition.vpc?.subnets?.management === 'use-existing' && appDefinition.vpc.subnets.ids) { + } else if (subnetManagement === 'use-existing' && appDefinition.vpc.subnets.ids) { translated.vpc.ownership.subnets = 'external'; translated.vpc.external.subnetIds = appDefinition.vpc.subnets.ids; } From 6ce515dac2f61926d79f890376ec88d05b2ae7a3 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:30:40 -0400 Subject: [PATCH 27/44] test(devtools): add a jest config that only collects *.test.js / *.spec.js devtools had no jest config, so Jest's default testMatch treated every file under __tests__/ as a suite: six fixture/helper modules (infrastructure/__tests__/{fixtures,helpers}, frigg-cli/__tests__/utils) failed with "must contain at least one test". It also collected management-ui's Vitest/JSX suites, which need their own runner and dependencies (management-ui is not an npm workspace, so npm ci never installs them). management-ui and the vendored Prisma layer are ignored. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- packages/devtools/jest.config.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 packages/devtools/jest.config.js diff --git a/packages/devtools/jest.config.js b/packages/devtools/jest.config.js new file mode 100644 index 000000000..681d13f05 --- /dev/null +++ b/packages/devtools/jest.config.js @@ -0,0 +1,24 @@ +/** + * Jest config for the @friggframework/devtools workspace (`npm test`). + * + * - Only `*.test.js` / `*.spec.js` files are suites. Jest's default testMatch + * also treats every file under `__tests__/` as a suite, which collected the + * shared fixtures/helpers (e.g. `__tests__/utils/mock-factory.js`) and + * failed them with "Your test suite must contain at least one test". + * - `management-ui/` is a Vite/React app with its own Vitest runner, jsdom + * environment and dependencies (`cd management-ui && npm install && npx vitest`). + * It is not an npm workspace, so its deps are not installed by `npm ci` at + * the repo root and its JSX suites cannot run under this Node/CommonJS Jest. + * - `layers/` holds the built Prisma Lambda layer (vendored node_modules). + */ +module.exports = { + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).js'], + testPathIgnorePatterns: [ + '/node_modules/', + '/management-ui/', + '/layers/', + ], + modulePathIgnorePatterns: ['/layers/'], + watchPathIgnorePatterns: ['/layers/'], +}; From 65014bca74d5e1afb6e13e77d0dc6905f6f422ee Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:30:53 -0400 Subject: [PATCH 28/44] test(devtools): update composer tests to the domain-builder composer, never build the Prisma layer or call AWS - A composer test spied fs.existsSync to return false and failed before mockRestore(), so every later test saw no .build-complete marker and ensurePrismaLayerExists() ran a real npm install into the tracked layers/prisma (~40 s, dirty tree). template-generation had no mocks and ran real AWS discovery. Both now stub prisma-layer-manager and gatherDiscoveredResources, as scaffold-default-infrastructure.test.js already does; the suites take under a second. - infrastructure-composer.test.js still targeted the pre-refactor monolith (7b36f0f4): it required the deleted aws-discovery module and asserted old logical IDs (VPCEndpointS3, FriggNATRoute), the jetpack/kms-grants plugins, a manual dbMigrate function (replaced in 3bae110e) and composer-thrown validation messages. It now asserts the current IDs (FriggS3VPCEndpoint, FriggPrivateRoute, FriggVPCEndpointSecurityGroup), the KMS env-var fallback, the migration router/worker pipeline and the orchestrator's validation flow. - 15 tests were removed because their behaviour moved to a builder and is covered there: NAT placement/management (vpc-builder.test.js, vpc-resolver.test.js), discovery gating (resource-discovery.test.js), plus a CIDR fallback removed from source and one duplicate. - template-generation's VPC cases use management: 'create-new'; with no VPC found, 'auto' is an explicit error since 37a354e8. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- .../__tests__/template-generation.test.js | 44 +- .../infrastructure-composer.test.js | 1062 +++++------------ 2 files changed, 304 insertions(+), 802 deletions(-) diff --git a/packages/devtools/infrastructure/__tests__/template-generation.test.js b/packages/devtools/infrastructure/__tests__/template-generation.test.js index 085f609fe..6ceeee9a4 100644 --- a/packages/devtools/infrastructure/__tests__/template-generation.test.js +++ b/packages/devtools/infrastructure/__tests__/template-generation.test.js @@ -5,6 +5,17 @@ * valid, consistent CloudFormation templates. */ +// Never touch AWS or the real Prisma layer from a unit test: discovery returns +// nothing (a fresh account), and the layer build (npm install into +// layers/prisma) is stubbed out. +jest.mock('../domains/shared/resource-discovery', () => ({ + ...jest.requireActual('../domains/shared/resource-discovery'), + gatherDiscoveredResources: jest.fn().mockResolvedValue({}), +})); +jest.mock('../domains/shared/utilities/prisma-layer-manager', () => ({ + ensurePrismaLayerExists: jest.fn().mockResolvedValue(undefined), +})); + const { composeServerlessDefinition } = require('../infrastructure-composer'); describe('CloudFormation Template Generation', () => { @@ -86,11 +97,16 @@ describe('CloudFormation Template Generation', () => { // Framework-generated resources (created by Serverless Framework, not our builders) const frameworkResources = ['HttpApi', 'HttpApiLogGroup']; + // Serverless names each function's resource `LambdaFunction` + // (e.g. dbMigrationWorker -> DbMigrationWorkerLambdaFunction). + const functionResources = Object.keys(result.functions || {}).map( + (name) => `${name.charAt(0).toUpperCase()}${name.slice(1)}LambdaFunction` + ); - // Verify all Refs point to existing resources, layers, or framework resources + // Verify all Refs point to existing resources, layers, functions or framework resources refs.forEach(ref => { if (!frameworkResources.includes(ref)) { - expect(allIds).toContain(ref); + expect([...allIds, ...functionResources]).toContain(ref); } }); }); @@ -123,7 +139,9 @@ describe('CloudFormation Template Generation', () => { integrations: [ { Definition: { name: 'slack' } } ], - vpc: { enable: true }, + // Discovery is stubbed to an empty account, so the VPC must be + // created explicitly; `auto` with no VPC found is a hard error. + vpc: { enable: true, management: 'create-new' }, encryption: { fieldLevelEncryptionMethod: 'kms' } }; @@ -251,7 +269,9 @@ describe('CloudFormation Template Generation', () => { it('should create VPC resources when enabled', async () => { const appDefinition = { name: 'test-app', - vpc: { enable: true }, + // Discovery is stubbed to an empty account, so the VPC must be + // created explicitly; `auto` with no VPC found is a hard error. + vpc: { enable: true, management: 'create-new' }, integrations: [] }; @@ -264,7 +284,9 @@ describe('CloudFormation Template Generation', () => { it('should create subnets in VPC', async () => { const appDefinition = { name: 'test-app', - vpc: { enable: true }, + // Discovery is stubbed to an empty account, so the VPC must be + // created explicitly; `auto` with no VPC found is a hard error. + vpc: { enable: true, management: 'create-new' }, integrations: [] }; @@ -277,7 +299,9 @@ describe('CloudFormation Template Generation', () => { it('should create security group in VPC', async () => { const appDefinition = { name: 'test-app', - vpc: { enable: true }, + // Discovery is stubbed to an empty account, so the VPC must be + // created explicitly; `auto` with no VPC found is a hard error. + vpc: { enable: true, management: 'create-new' }, integrations: [] }; @@ -290,7 +314,9 @@ describe('CloudFormation Template Generation', () => { it('should configure Lambda functions with VPC', async () => { const appDefinition = { name: 'test-app', - vpc: { enable: true }, + // Discovery is stubbed to an empty account, so the VPC must be + // created explicitly; `auto` with no VPC found is a hard error. + vpc: { enable: true, management: 'create-new' }, integrations: [ { Definition: { name: 'slack' } } ] @@ -427,7 +453,9 @@ describe('CloudFormation Template Generation', () => { { Definition: { name: 'slack' } }, { Definition: { name: 'hubspot' } } ], - vpc: { enable: true }, + // Discovery is stubbed to an empty account, so the VPC must be + // created explicitly; `auto` with no VPC found is a hard error. + vpc: { enable: true, management: 'create-new' }, encryption: { useDefaultKMSForFieldLevelEncryption: true }, database: { type: 'postgresql', diff --git a/packages/devtools/infrastructure/infrastructure-composer.test.js b/packages/devtools/infrastructure/infrastructure-composer.test.js index ae232c7b4..a957aa1e4 100644 --- a/packages/devtools/infrastructure/infrastructure-composer.test.js +++ b/packages/devtools/infrastructure/infrastructure-composer.test.js @@ -35,8 +35,29 @@ jest.mock('./domains/shared/resource-discovery', () => { }; }); +// The Prisma layer build runs `npm install` into packages/devtools/layers/prisma; +// a unit test must never do that (it took ~40 s and rewrote tracked files). +jest.mock('./domains/shared/utilities/prisma-layer-manager', () => ({ + ensurePrismaLayerExists: jest.fn().mockResolvedValue(undefined), +})); + const { gatherDiscoveredResources } = require('./domains/shared/resource-discovery'); +// The orchestrator logs each builder's validation errors, then throws one +// summary error; assert both. +async function expectValidationError(appDefinition, message) { + const logSpy = jest.spyOn(console, 'log'); + try { + await expect(composeServerlessDefinition(appDefinition)).rejects.toThrow( + 'Infrastructure validation failed' + ); + const logged = logSpy.mock.calls.map((args) => args.join(' ')).join('\n'); + expect(logged).toContain(message); + } finally { + logSpy.mockRestore(); + } +} + describe('composeServerlessDefinition', () => { let mockIntegration; @@ -65,21 +86,11 @@ describe('composeServerlessDefinition', () => { process.argv = ['node', 'test']; }); + // Whether discovery actually talks to AWS (no features need it, or + // FRIGG_SKIP_AWS_DISCOVERY=true) is decided inside gatherDiscoveredResources + // by shouldRunDiscovery(); see domains/shared/resource-discovery.test.js. + // The composer always calls it, so these tests only check the call happens. describe('AWS discovery gating', () => { - it('should skip AWS discovery when no features require it', async () => { - gatherDiscoveredResources.mockClear(); - - const appDefinition = { - integrations: [], - vpc: { enable: false }, - encryption: { fieldLevelEncryptionMethod: 'aes' }, - ssm: { enable: false }, - }; - - await composeServerlessDefinition(appDefinition); - - expect(gatherDiscoveredResources).not.toHaveBeenCalled(); - }); it('should run AWS discovery when VPC features are enabled', async () => { gatherDiscoveredResources.mockClear(); @@ -94,22 +105,6 @@ describe('composeServerlessDefinition', () => { expect(gatherDiscoveredResources).toHaveBeenCalledTimes(1); }); - it('should skip AWS discovery when FRIGG_SKIP_AWS_DISCOVERY is set to true', async () => { - gatherDiscoveredResources.mockClear(); - process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true'; - - const appDefinition = { - integrations: [], - vpc: { enable: true }, - encryption: { fieldLevelEncryptionMethod: 'kms' }, - ssm: { enable: true }, - }; - - await composeServerlessDefinition(appDefinition); - - expect(gatherDiscoveredResources).not.toHaveBeenCalled(); - }); - it('should run AWS discovery when FRIGG_SKIP_AWS_DISCOVERY is not set', async () => { gatherDiscoveredResources.mockClear(); delete process.env.FRIGG_SKIP_AWS_DISCOVERY; @@ -420,11 +415,15 @@ describe('composeServerlessDefinition', () => { }); it('should respect provided security group IDs when supplied', async () => { + // User-supplied security groups are honoured for an existing VPC + // (vpc.management 'use-existing'); every ID is attached, not just the first. const appDefinition = { vpc: { enable: true, - management: 'discover', + management: 'use-existing', + vpcId: 'vpc-123456', securityGroupIds: ['sg-custom-1', 'sg-custom-2'], + subnets: { ids: ['subnet-123456', 'subnet-789012'] }, }, integrations: [], }; @@ -438,27 +437,25 @@ describe('composeServerlessDefinition', () => { }); it('should throw when discover mode finds no subnets and self-heal is disabled', async () => { - const discoveryInstance = { - discoverResources: jest.fn().mockResolvedValue( - createDiscoveryResponse({ - privateSubnetId1: null, - privateSubnetId2: null, - }) - ), - }; - AWSDiscovery.mockImplementation(() => discoveryInstance); + gatherDiscoveredResources.mockResolvedValue( + createDiscoveryResponse({ + privateSubnetId1: null, + privateSubnetId2: null, + }) + ); const appDefinition = { vpc: { enable: true, management: 'discover', + selfHeal: false, subnets: { management: 'discover' }, }, integrations: [], }; await expect(composeServerlessDefinition(appDefinition)).rejects.toThrow( - 'No subnets discovered and subnets.management is "discover". Either enable vpc.selfHeal, set subnets.management to "create", or provide subnet IDs.' + 'No subnets discovered. Enable vpc.selfHeal, set subnets.management to "create", or provide subnet IDs.' ); }); @@ -492,6 +489,11 @@ describe('composeServerlessDefinition', () => { }); it('should create route tables for subnets even without NAT Gateway management', async () => { + // A fresh account: nothing to reuse, so the stack builds its own routing. + gatherDiscoveredResources.mockResolvedValue( + createDiscoveryResponse({ existingNatGatewayId: null }) + ); + const appDefinition = { vpc: { enable: true, @@ -510,7 +512,7 @@ describe('composeServerlessDefinition', () => { expect(result.resources.Resources.FriggLambdaRouteTable).toBeDefined(); // Verify subnet associations - expect(result.resources.Resources.FriggPublicSubnetRouteTableAssociation).toBeDefined(); + expect(result.resources.Resources.FriggPublicSubnet1RouteTableAssociation).toBeDefined(); expect(result.resources.Resources.FriggPrivateSubnet1RouteTableAssociation).toBeDefined(); expect(result.resources.Resources.FriggPrivateSubnet2RouteTableAssociation).toBeDefined(); }); @@ -524,8 +526,9 @@ describe('composeServerlessDefinition', () => { integrations: [] }; - await expect(composeServerlessDefinition(appDefinition)).rejects.toThrow( - 'VPC management is set to "use-existing" but no vpcId was provided' + await expectValidationError( + appDefinition, + 'vpc.vpcId is required when management="use-existing"' ); }); @@ -553,9 +556,11 @@ describe('composeServerlessDefinition', () => { const result = await composeServerlessDefinition(appDefinition); - expect(result.resources.Resources.VPCEndpointS3).toBeDefined(); - expect(result.resources.Resources.VPCEndpointS3.Type).toBe('AWS::EC2::VPCEndpoint'); - expect(result.resources.Resources.VPCEndpointS3.Properties.VpcId).toBe('vpc-123456'); + const s3Endpoint = result.resources.Resources.FriggS3VPCEndpoint; + expect(s3Endpoint).toBeDefined(); + expect(s3Endpoint.Type).toBe('AWS::EC2::VPCEndpoint'); + expect(s3Endpoint.Properties.VpcId).toBe('vpc-123456'); + expect(s3Endpoint.Properties.VpcEndpointType).toBe('Gateway'); }); it('should skip creating VPC endpoints when disabled explicitly', async () => { @@ -570,9 +575,9 @@ describe('composeServerlessDefinition', () => { const result = await composeServerlessDefinition(appDefinition); - expect(result.resources.Resources.VPCEndpointS3).toBeUndefined(); - expect(result.resources.Resources.VPCEndpointKMS).toBeUndefined(); - expect(result.resources.Resources.VPCEndpointSecretsManager).toBeUndefined(); + expect(result.resources.Resources.FriggS3VPCEndpoint).toBeUndefined(); + expect(result.resources.Resources.FriggKMSVPCEndpoint).toBeUndefined(); + expect(result.resources.Resources.FriggSecretsManagerVPCEndpoint).toBeUndefined(); }); it('should add Secrets Manager endpoint only when enabled', async () => { @@ -588,7 +593,8 @@ describe('composeServerlessDefinition', () => { const result = await composeServerlessDefinition(appDefinition); - expect(result.resources.Resources.VPCEndpointSecretsManager).toBeDefined(); + expect(result.resources.Resources.FriggSecretsManagerVPCEndpoint).toBeDefined(); + expect(result.resources.Resources.FriggKMSVPCEndpoint).toBeDefined(); }); it('should allow Lambda security group access for VPC endpoints when security group is discovered', async () => { @@ -602,7 +608,7 @@ describe('composeServerlessDefinition', () => { }; const result = await composeServerlessDefinition(appDefinition); - const endpointSg = result.resources.Resources.VPCEndpointSecurityGroup; + const endpointSg = result.resources.Resources.FriggVPCEndpointSecurityGroup; expect(endpointSg).toBeDefined(); expect(endpointSg.Properties.SecurityGroupIngress).toEqual([ @@ -611,81 +617,39 @@ describe('composeServerlessDefinition', () => { FromPort: 443, ToPort: 443, SourceSecurityGroupId: 'sg-123456', - Description: 'HTTPS from Lambda security group' + Description: 'HTTPS from Lambda' } ]); }); - it('should fall back to VPC CIDR when Lambda security group identifier cannot be resolved', async () => { - AWSDiscovery.mockImplementation(() => ({ - discoverResources: jest - .fn() - .mockResolvedValue(createDiscoveryResponse()), - })); - - const appDefinition = { - vpc: { - enable: true, - management: 'discover', - securityGroupIds: [ - { - 'Fn::ImportValue': 'shared-lambda-security-group', - }, - ], - }, - encryption: { fieldLevelEncryptionMethod: 'kms' }, - integrations: [], - }; - - const result = await composeServerlessDefinition(appDefinition); - const endpointSg = result.resources.Resources.VPCEndpointSecurityGroup; - - expect(endpointSg).toBeDefined(); - expect(endpointSg.Properties.SecurityGroupIngress).toEqual([ - { - IpProtocol: 'tcp', - FromPort: 443, - ToPort: 443, - CidrIp: '172.31.0.0/16', - Description: 'HTTPS from VPC CIDR (fallback)', - }, - ]); - }); - - it('should fall back to default private ranges when neither Lambda security group nor VPC CIDR is available', async () => { - AWSDiscovery.mockImplementation(() => ({ - discoverResources: jest - .fn() - .mockResolvedValue( - createDiscoveryResponse({ vpcCidr: null }) - ), - })); - + it('should pass an imported Lambda security group through to the endpoint ingress rule', async () => { + // CloudFormation resolves Fn::ImportValue to the group ID at deploy + // time, so the endpoint SG can reference it directly (the old + // VPC-CIDR fallback for unresolvable groups is no longer needed). + const importedSg = { 'Fn::ImportValue': 'shared-lambda-security-group' }; const appDefinition = { vpc: { enable: true, - management: 'discover', - securityGroupIds: [ - { - 'Fn::ImportValue': 'shared-lambda-security-group', - }, - ], + management: 'use-existing', + vpcId: 'vpc-123456', + securityGroupIds: [importedSg], + subnets: { ids: ['subnet-123456', 'subnet-789012'] }, }, encryption: { fieldLevelEncryptionMethod: 'kms' }, integrations: [], }; const result = await composeServerlessDefinition(appDefinition); - const endpointSg = result.resources.Resources.VPCEndpointSecurityGroup; + const endpointSg = result.resources.Resources.FriggVPCEndpointSecurityGroup; - expect(endpointSg).toBeDefined(); + expect(result.provider.vpc.securityGroupIds).toEqual([importedSg]); expect(endpointSg.Properties.SecurityGroupIngress).toEqual([ { IpProtocol: 'tcp', FromPort: 443, ToPort: 443, - CidrIp: '172.31.0.0/16', - Description: 'HTTPS from default VPC range', + SourceSecurityGroupId: importedSg, + Description: 'HTTPS from Lambda', }, ]); }); @@ -710,14 +674,7 @@ describe('composeServerlessDefinition', () => { FromPort: 443, ToPort: 443, SourceSecurityGroupId: { Ref: 'FriggLambdaSecurityGroup' }, - Description: 'HTTPS from Lambda security group' - }, - { - IpProtocol: 'tcp', - FromPort: 443, - ToPort: 443, - CidrIp: '10.0.0.0/16', - Description: 'HTTPS from VPC CIDR (fallback)' + Description: 'HTTPS from Lambda' } ]); }); @@ -731,7 +688,7 @@ describe('composeServerlessDefinition', () => { const result = await composeServerlessDefinition(appDefinition); expect(result.provider.vpc).toBeUndefined(); - expect(result.resources.Resources.VPCEndpointS3).toBeUndefined(); + expect(result.resources.Resources.FriggS3VPCEndpoint).toBeUndefined(); }); it('should not add VPC configuration when vpc is not defined', async () => { @@ -745,116 +702,87 @@ describe('composeServerlessDefinition', () => { }); }); + // NAT placement rules (never reuse a NAT in a private subnet, self-heal, + // EIP reuse, route-table association repair) are VpcBuilder decisions and + // are covered in domains/networking/vpc-builder.test.js ('NAT Gateway + // handling') and vpc-resolver.test.js. These check the composed template. describe('NAT Gateway behaviour', () => { - it('should reuse discovered NAT gateway in discover mode without creating new resources', async () => { - const discoveryInstance = { - discoverResources: jest.fn().mockResolvedValue( - createDiscoveryResponse({ - existingNatGatewayId: 'nat-existing123', - natGatewayInPrivateSubnet: false, - }) - ), - }; - AWSDiscovery.mockImplementation(() => discoveryInstance); + it('should route through a discovered NAT gateway without creating a new one', async () => { + gatherDiscoveredResources.mockResolvedValue( + createDiscoveryResponse({ + existingNatGatewayId: 'nat-existing123', + natGatewayInPrivateSubnet: false, + }) + ); - const appDefinition = { + const result = await composeServerlessDefinition({ vpc: { enable: true, management: 'discover', natGateway: { management: 'discover' }, }, integrations: [], - }; - - const result = await composeServerlessDefinition(appDefinition); + }); expect(result.resources.Resources.FriggNATGateway).toBeUndefined(); - expect(result.resources.Resources.FriggNATRoute).toBeDefined(); + expect(result.resources.Resources.FriggPrivateRoute.Properties).toEqual({ + RouteTableId: { Ref: 'FriggLambdaRouteTable' }, + DestinationCidrBlock: '0.0.0.0/0', + NatGatewayId: 'nat-existing123', + }); }); - it('should reference provided NAT gateway when management set to useExisting', async () => { - const discoveryInstance = { - discoverResources: jest.fn().mockResolvedValue( - createDiscoveryResponse({ existingNatGatewayId: null }) - ), - }; - AWSDiscovery.mockImplementation(() => discoveryInstance); + it('should route through a provided NAT gateway ID', async () => { + gatherDiscoveredResources.mockResolvedValue( + createDiscoveryResponse({ existingNatGatewayId: null }) + ); - const appDefinition = { + const result = await composeServerlessDefinition({ vpc: { enable: true, management: 'discover', natGateway: { management: 'useExisting', id: 'nat-custom-001' }, }, integrations: [], - }; - - const result = await composeServerlessDefinition(appDefinition); + }); expect(result.resources.Resources.FriggNATGateway).toBeUndefined(); - expect(result.resources.Resources.FriggNATRoute.Properties.NatGatewayId).toBe('nat-custom-001'); - }); - - it('should reuse existing elastic IP allocation when creating managed NAT', async () => { - const discoveryInstance = { - discoverResources: jest.fn().mockResolvedValue( - createDiscoveryResponse({ - existingNatGatewayId: null, - existingElasticIpAllocationId: 'eip-alloc-123', - }) - ), - }; - AWSDiscovery.mockImplementation(() => discoveryInstance); - - const appDefinition = { - vpc: { - enable: true, - management: 'discover', - natGateway: { management: 'createAndManage' }, - selfHeal: true, - }, - integrations: [], - }; - - const result = await composeServerlessDefinition(appDefinition); - - expect(result.resources.Resources.FriggNATGatewayEIP).toBeUndefined(); - expect(result.resources.Resources.FriggNATGateway.Properties.AllocationId).toBe( - 'eip-alloc-123' + expect(result.resources.Resources.FriggPrivateRoute.Properties.NatGatewayId).toBe( + 'nat-custom-001' ); }); - it('should create a public subnet when discovery provides none', async () => { - const discoveryInstance = { - discoverResources: jest.fn().mockResolvedValue( - createDiscoveryResponse({ - publicSubnetId: null, - internetGatewayId: null, - existingNatGatewayId: null, - }) - ), - }; - AWSDiscovery.mockImplementation(() => discoveryInstance); + it('should create a retained NAT gateway in a public subnet with createAndManage', async () => { + gatherDiscoveredResources.mockResolvedValue( + createDiscoveryResponse({ existingNatGatewayId: null }) + ); - const appDefinition = { + const result = await composeServerlessDefinition({ vpc: { enable: true, management: 'discover', natGateway: { management: 'createAndManage' }, - selfHeal: true, }, integrations: [], - }; - - const result = await composeServerlessDefinition(appDefinition); + }); - expect(result.resources.Resources.FriggPublicSubnet).toBeDefined(); - expect(result.resources.Resources.FriggPublicRouteTable).toBeDefined(); + const resources = result.resources.Resources; + expect(resources.FriggNATGateway.Type).toBe('AWS::EC2::NatGateway'); + expect(resources.FriggNATGateway.DeletionPolicy).toBe('Retain'); + expect(resources.FriggNATGateway.UpdateReplacePolicy).toBe('Retain'); + expect(resources.FriggNATGateway.Properties.SubnetId).toBe('subnet-public-1'); + expect(resources.FriggNATGatewayEIP).toBeDefined(); + expect(resources.FriggPublicRoute).toBeDefined(); + expect(resources.FriggPrivateRoute.Properties.NatGatewayId).toEqual({ + Ref: 'FriggNATGateway', + }); }); }); describe('KMS Configuration', () => { it('should add KMS configuration when encryption is enabled and key is found', async () => { + const keyArn = + 'arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012'; const appDefinition = { encryption: { fieldLevelEncryptionMethod: 'kms' }, integrations: [] @@ -862,7 +790,8 @@ describe('composeServerlessDefinition', () => { const result = await composeServerlessDefinition(appDefinition); - // Check IAM permissions + // IAM: the Lambda role uses the discovered key directly (no + // serverless-kms-grants plugin, which KmsBuilder no longer uses). const kmsPermission = result.provider.iamRoleStatements.find( statement => statement.Action.includes('kms:GenerateDataKey') ); @@ -870,49 +799,26 @@ describe('composeServerlessDefinition', () => { Effect: 'Allow', Action: [ 'kms:GenerateDataKey', - 'kms:Decrypt' + 'kms:Decrypt', + 'kms:Encrypt', + 'kms:DescribeKey' ], - Resource: ['arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012'] + Resource: keyArn }); - // Check environment variable - expect(result.provider.environment.KMS_KEY_ARN).toBe('arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012'); - - // Check plugin - expect(result.plugins).toContain('serverless-kms-grants'); - - // Check custom configuration - expect(result.custom.kmsGrants).toEqual({ - kmsKeyId: 'arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012' - }); - - // Check KMS Alias resource is created for discovered key - expect(result.resources.Resources.FriggKMSKeyAlias).toBeDefined(); - expect(result.resources.Resources.FriggKMSKeyAlias).toEqual({ - Type: 'AWS::KMS::Alias', - DeletionPolicy: 'Retain', - Properties: { - AliasName: 'alias/${self:service}-${self:provider.stage}-frigg-kms', - TargetKeyId: 'arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012' - } - }); + expect(result.provider.environment.KMS_KEY_ARN).toBe(keyArn); + expect(result.plugins).not.toContain('serverless-kms-grants'); + expect(result.custom.kmsGrants).toBeUndefined(); + // A discovered key is referenced, not re-declared in the stack. + expect(result.resources.Resources.FriggKMSKey).toBeUndefined(); }); it('should create new KMS key when encryption is enabled, no key found, and createResourceIfNoneFound is true', async () => { - // Mock AWS discovery to return no KMS key - const { AWSDiscovery } = require('./aws-discovery'); - const mockDiscoverResources = jest.fn().mockResolvedValue({ - defaultVpcId: 'vpc-123456', - defaultSecurityGroupId: 'sg-123456', - privateSubnetId1: 'subnet-123456', - privateSubnetId2: 'subnet-789012', - publicSubnetId: 'subnet-public', - defaultRouteTableId: 'rtb-123456', - defaultKmsKeyId: null // No KMS key found - }); - AWSDiscovery.mockImplementation(() => ({ - discoverResources: mockDiscoverResources - })); + // Key policy, rotation and alias details are covered in + // domains/security/kms-builder.test.js. + gatherDiscoveredResources.mockResolvedValue( + createDiscoveryResponse({ defaultKmsKeyId: null }) + ); const appDefinition = { encryption: { @@ -924,116 +830,25 @@ describe('composeServerlessDefinition', () => { const result = await composeServerlessDefinition(appDefinition); - // Check that KMS key resource was created with DeletionPolicy - expect(result.resources.Resources.FriggKMSKey).toEqual({ - Type: 'AWS::KMS::Key', - DeletionPolicy: 'Retain', - UpdateReplacePolicy: 'Retain', - Properties: { - EnableKeyRotation: true, - Description: 'Frigg KMS key for field-level encryption', - KeyPolicy: { - Version: '2012-10-17', - Statement: [ - { - Sid: 'AllowRootAccountAdmin', - Effect: 'Allow', - Principal: { - AWS: { - 'Fn::Sub': 'arn:aws:iam::${AWS::AccountId}:root' - } - }, - Action: 'kms:*', - Resource: '*' - }, - { - Sid: 'AllowLambdaService', - Effect: 'Allow', - Principal: { - Service: 'lambda.amazonaws.com' - }, - Action: [ - 'kms:GenerateDataKey', - 'kms:Decrypt', - 'kms:DescribeKey' - ], - Resource: '*', - Condition: { - StringEquals: { - 'kms:ViaService': 'lambda.us-east-1.amazonaws.com' - } - } - } - ] - }, - Tags: [ - { - Key: 'Name', - Value: '${self:service}-${self:provider.stage}-frigg-kms-key' - }, - { - Key: 'ManagedBy', - Value: 'Frigg' - }, - { - Key: 'Purpose', - Value: 'Field-level encryption for Frigg application' - } - ] - } - }); + const key = result.resources.Resources.FriggKMSKey; + expect(key.Type).toBe('AWS::KMS::Key'); + expect(key.DeletionPolicy).toBe('Retain'); + expect(key.Properties.EnableKeyRotation).toBe(true); - // Check KMS Alias resource is created for the new key - expect(result.resources.Resources.FriggKMSKeyAlias).toBeDefined(); - expect(result.resources.Resources.FriggKMSKeyAlias).toEqual({ - Type: 'AWS::KMS::Alias', - DeletionPolicy: 'Retain', - Properties: { - AliasName: 'alias/${self:service}-${self:provider.stage}-frigg-kms', - TargetKeyId: { 'Fn::GetAtt': ['FriggKMSKey', 'Arn'] } - } - }); - - // Check IAM permissions for the new key + const keyRef = { 'Fn::GetAtt': ['FriggKMSKey', 'Arn'] }; const kmsPermission = result.provider.iamRoleStatements.find( statement => statement.Action.includes('kms:GenerateDataKey') ); - expect(kmsPermission).toEqual({ - Effect: 'Allow', - Action: ['kms:GenerateDataKey', 'kms:Decrypt'], - Resource: [{ 'Fn::GetAtt': ['FriggKMSKey', 'Arn'] }] - }); - - // Check environment variable - expect(result.provider.environment.KMS_KEY_ARN).toEqual({ - 'Fn::GetAtt': ['FriggKMSKey', 'Arn'] - }); - - // Check plugin - expect(result.plugins).toContain('serverless-kms-grants'); - - // Check custom configuration - // When creating a new key, it should reference the CloudFormation resource - expect(result.custom.kmsGrants).toEqual({ - kmsKeyId: { 'Fn::GetAtt': ['FriggKMSKey', 'Arn'] } - }); + expect(kmsPermission.Resource).toEqual(keyRef); + expect(result.provider.environment.KMS_KEY_ARN).toEqual(keyRef); }); - it('should throw error when encryption is enabled, no key found, and createResourceIfNoneFound is false', async () => { - // Mock AWS discovery to return no KMS key - const { AWSDiscovery } = require('./aws-discovery'); - const mockDiscoverResources = jest.fn().mockResolvedValue({ - defaultVpcId: 'vpc-123456', - defaultSecurityGroupId: 'sg-123456', - privateSubnetId1: 'subnet-123456', - privateSubnetId2: 'subnet-789012', - publicSubnetId: 'subnet-public', - defaultRouteTableId: 'rtb-123456', - defaultKmsKeyId: null // No KMS key found - }); - AWSDiscovery.mockImplementation(() => ({ - discoverResources: mockDiscoverResources - })); + it('should fall back to the AWS_DISCOVERY_KMS_KEY_ID env reference when encryption is enabled, no key found, and createResourceIfNoneFound is false', async () => { + // KmsBuilder no longer fails the build: it points the app at the + // key ID the deploy environment provides (AWS_DISCOVERY_KMS_KEY_ID). + gatherDiscoveredResources.mockResolvedValue( + createDiscoveryResponse({ defaultKmsKeyId: null }) + ); const appDefinition = { encryption: { @@ -1043,27 +858,22 @@ describe('composeServerlessDefinition', () => { integrations: [] }; - await expect(composeServerlessDefinition(appDefinition)).rejects.toThrow( - 'KMS field-level encryption is enabled but no KMS key was found. ' + - 'Either provide an existing KMS key or set encryption.createResourceIfNoneFound to true to create a new key.' + const result = await composeServerlessDefinition(appDefinition); + + // An explicit `false` keeps the legacy raw env reference (see + // kms-builder.test.js 'Error handling'). + expect(result.resources.Resources.FriggKMSKey).toBeUndefined(); + expect(result.provider.environment.KMS_KEY_ARN).toBe( + '${env:AWS_DISCOVERY_KMS_KEY_ID}' ); }); - it('should throw error when encryption is enabled, no key found, and createResourceIfNoneFound is not specified', async () => { - // Mock AWS discovery to return no KMS key - const { AWSDiscovery } = require('./aws-discovery'); - const mockDiscoverResources = jest.fn().mockResolvedValue({ - defaultVpcId: 'vpc-123456', - defaultSecurityGroupId: 'sg-123456', - privateSubnetId1: 'subnet-123456', - privateSubnetId2: 'subnet-789012', - publicSubnetId: 'subnet-public', - defaultRouteTableId: 'rtb-123456', - defaultKmsKeyId: null // No KMS key found - }); - AWSDiscovery.mockImplementation(() => ({ - discoverResources: mockDiscoverResources - })); + it('should fall back to the AWS_DISCOVERY_KMS_KEY_ID env reference when encryption is enabled, no key found, and createResourceIfNoneFound is not specified', async () => { + // KmsBuilder no longer fails the build: it points the app at the + // key ID the deploy environment provides (AWS_DISCOVERY_KMS_KEY_ID). + gatherDiscoveredResources.mockResolvedValue( + createDiscoveryResponse({ defaultKmsKeyId: null }) + ); const appDefinition = { encryption: { @@ -1073,10 +883,12 @@ describe('composeServerlessDefinition', () => { integrations: [] }; - await expect(composeServerlessDefinition(appDefinition)).rejects.toThrow( - 'KMS field-level encryption is enabled but no KMS key was found. ' + - 'Either provide an existing KMS key or set encryption.createResourceIfNoneFound to true to create a new key.' - ); + const result = await composeServerlessDefinition(appDefinition); + + const fallbackArn = + 'arn:aws:kms:${self:provider.region}:${aws:accountId}:key/${env:AWS_DISCOVERY_KMS_KEY_ID}'; + expect(result.resources.Resources.FriggKMSKey).toBeUndefined(); + expect(result.provider.environment.KMS_KEY_ARN).toBe(fallbackArn); }); it('should not add KMS configuration when encryption is disabled', async () => { @@ -1234,8 +1046,9 @@ describe('composeServerlessDefinition', () => { const result = await composeServerlessDefinition(appDefinition); - // Check integration function - expect(result.functions.testIntegration).toEqual({ + // Check integration function (packaging/layers are covered by the + // base-definition and integration-builder tests) + expect(result.functions.testIntegration).toEqual(expect.objectContaining({ handler: 'node_modules/@friggframework/core/handlers/routers/integration-defined-routers.handlers.testIntegration.handler', events: [{ httpApi: { @@ -1243,7 +1056,7 @@ describe('composeServerlessDefinition', () => { method: 'ANY' } }] - }); + })); // Check SQS Queue expect(result.resources.Resources.TestIntegrationQueue).toEqual({ @@ -1262,7 +1075,7 @@ describe('composeServerlessDefinition', () => { }); // Check Queue Worker - expect(result.functions.testIntegrationQueueWorker).toEqual({ + expect(result.functions.testIntegrationQueueWorker).toEqual(expect.objectContaining({ handler: 'node_modules/@friggframework/core/handlers/workers/integration-defined-workers.handlers.testIntegration.queueWorker', reservedConcurrency: 20, events: [{ @@ -1274,8 +1087,9 @@ describe('composeServerlessDefinition', () => { functionResponseType: 'ReportBatchItemFailures' } }], - timeout: 600 - }); + // Lambda maximum; the queue's VisibilityTimeout stays above it + timeout: 900 + })); // Check environment variable expect(result.provider.environment.TESTINTEGRATION_QUEUE_URL).toEqual({ @@ -1312,146 +1126,20 @@ describe('composeServerlessDefinition', () => { const fs = require('fs'); const path = require('path'); - it('should rewrite handler paths in offline mode', async () => { + it('should keep node_modules handler paths in offline mode', async () => { + // serverless-offline resolves node_modules from the working + // directory, so handler paths are no longer rewritten to ../node_modules. process.argv = ['node', 'test', 'offline']; - const existsSpy = jest.spyOn(fs, 'existsSync'); - const fallbackNodeModules = path.resolve(process.cwd(), '..', 'node_modules'); - existsSpy.mockImplementation((p) => p === fallbackNodeModules); - - const appDefinition = { integrations: [] }; - const result = await composeServerlessDefinition(appDefinition); - - expect(existsSpy).toHaveBeenCalled(); - expect(result.functions.auth.handler.startsWith('../node_modules/')).toBe(true); + const result = await composeServerlessDefinition({ integrations: [] }); - existsSpy.mockRestore(); + expect(result.functions.auth.handler).toBe( + 'node_modules/@friggframework/core/handlers/routers/auth.handler' + ); }); }); describe('NAT Gateway Management', () => { - it('should handle NAT Gateway with createAndManage mode', async () => { - const appDefinition = { - vpc: { - enable: true, - management: 'discover', - natGateway: { - management: 'createAndManage' - } - }, - integrations: [] - }; - - const result = await composeServerlessDefinition(appDefinition); - - expect(result.resources.Resources.FriggLambdaRouteTable).toBeDefined(); - expect(result.resources.Resources.FriggNATRoute).toBeDefined(); - }); - - it('should mark managed NAT Gateway resources for retention', async () => { - const appDefinition = { - vpc: { - enable: true, - management: 'discover', - natGateway: { management: 'createAndManage' }, - selfHeal: true, - }, - integrations: [], - }; - - const result = await composeServerlessDefinition(appDefinition); - - expect(result.resources.Resources.FriggNATGateway).toBeDefined(); - expect(result.resources.Resources.FriggNATGateway.DeletionPolicy).toBe('Retain'); - expect(result.resources.Resources.FriggNATGateway.UpdateReplacePolicy).toBe('Retain'); - }); - - it('should handle NAT Gateway with discover mode', async () => { - // Mock discovery to return existing NAT Gateway - const { AWSDiscovery } = require('./aws-discovery'); - const mockDiscoverResources = jest.fn().mockResolvedValue({ - defaultVpcId: 'vpc-123456', - defaultSecurityGroupId: 'sg-123456', - privateSubnetId1: 'subnet-123456', - privateSubnetId2: 'subnet-789012', - publicSubnetId: 'subnet-public', - defaultRouteTableId: 'rtb-123456', - defaultKmsKeyId: null, - existingNatGatewayId: 'nat-existing123' - }); - AWSDiscovery.mockImplementation(() => ({ - discoverResources: mockDiscoverResources - })); - - const appDefinition = { - vpc: { - enable: true, - management: 'discover', - natGateway: { - management: 'discover' - } - }, - integrations: [] - }; - - const result = await composeServerlessDefinition(appDefinition); - - expect(result.resources.Resources.FriggNATRoute).toBeDefined(); - expect(result.resources.Resources.FriggNATRoute.Properties.NatGatewayId).toBe('nat-existing123'); - }); - - it('should handle NAT Gateway with useExisting mode and provided ID', async () => { - const appDefinition = { - vpc: { - enable: true, - management: 'discover', - natGateway: { - management: 'useExisting', - id: 'nat-custom456' - } - }, - integrations: [] - }; - - const result = await composeServerlessDefinition(appDefinition); - - expect(result.resources.Resources.FriggNATRoute).toBeDefined(); - expect(result.resources.Resources.FriggNATRoute.Properties.NatGatewayId).toBe('nat-custom456'); - }); - - it('should throw error when NAT Gateway not found in discover mode', async () => { - // Mock discovery to return no NAT Gateway - const { AWSDiscovery } = require('./aws-discovery'); - const mockDiscoverResources = jest.fn().mockResolvedValue({ - defaultVpcId: 'vpc-123456', - defaultSecurityGroupId: 'sg-123456', - privateSubnetId1: 'subnet-123456', - privateSubnetId2: 'subnet-789012', - publicSubnetId: 'subnet-public', - defaultRouteTableId: 'rtb-123456', - defaultKmsKeyId: null, - existingNatGatewayId: null // No NAT Gateway - }); - AWSDiscovery.mockImplementation(() => ({ - discoverResources: mockDiscoverResources - })); - - const appDefinition = { - vpc: { - enable: true, - management: 'discover', - natGateway: { - management: 'discover' - } - }, - integrations: [] - }; - - await expect(composeServerlessDefinition(appDefinition)).rejects.toThrow( - 'No existing NAT Gateway found in discovery mode' - ); - }); - it('should enable self-healing when selfHeal is true', async () => { const appDefinition = { vpc: { @@ -1489,15 +1177,13 @@ describe('composeServerlessDefinition', () => { const result = await composeServerlessDefinition(appDefinition); - // VPC + // VPC (plus the interface endpoints KMS needs from private subnets) expect(result.provider.vpc).toBeDefined(); - // custom.vpc doesn't exist in the serverless template - expect(result.resources.Resources.VPCEndpointS3).toBeDefined(); + expect(result.resources.Resources.FriggS3VPCEndpoint).toBeDefined(); + expect(result.resources.Resources.FriggKMSVPCEndpoint).toBeDefined(); // KMS - expect(result.plugins).toContain('serverless-kms-grants'); expect(result.provider.environment.KMS_KEY_ARN).toBeDefined(); - expect(result.custom.kmsGrants).toBeDefined(); // SSM (enabled without offload markers -> broad grant only, no offload env vars) const ssmPermission = result.provider.iamRoleStatements.find( @@ -1510,14 +1196,12 @@ describe('composeServerlessDefinition', () => { expect(result.functions.testIntegration).toBeDefined(); expect(result.resources.Resources.TestIntegrationQueue).toBeDefined(); - // All plugins should be present + // No feature adds a Serverless plugin of its own expect(result.plugins).toEqual([ - 'serverless-jetpack', - 'serverless-dotenv-plugin', + 'serverless-esbuild', 'serverless-offline-sqs', 'serverless-offline', '@friggframework/serverless-plugin', - 'serverless-kms-grants' ]); }); @@ -1538,10 +1222,13 @@ describe('composeServerlessDefinition', () => { // VPC and KMS should be present expect(result.provider.vpc).toBeDefined(); - expect(result.custom.kmsGrants).toBeDefined(); + expect(result.provider.environment.KMS_KEY_ARN).toBeDefined(); // SSM should not be present - expect(result.provider.layers).toBeUndefined(); + const ssmPermission = result.provider.iamRoleStatements.find( + statement => statement.Action && statement.Action.includes('ssm:GetParameter') + ); + expect(ssmPermission).toBeUndefined(); expect(result.provider.environment.SSM_PARAMETER_PREFIX).toBeUndefined(); }); }); @@ -1565,10 +1252,14 @@ describe('composeServerlessDefinition', () => { expect(result.functions.user).toBeDefined(); expect(result.functions.health).toBeDefined(); - // Check default plugins - expect(result.plugins).toContain('serverless-jetpack'); - expect(result.plugins).toContain('serverless-dotenv-plugin'); - expect(result.plugins).toContain('@friggframework/serverless-plugin'); + // Check default plugins (esbuild replaced serverless-jetpack; env + // vars come from the app definition, not serverless-dotenv-plugin) + expect(result.plugins).toEqual([ + 'serverless-esbuild', + 'serverless-offline-sqs', + 'serverless-offline', + '@friggframework/serverless-plugin', + ]); }); it('should always include default IAM permissions', async () => { @@ -1598,8 +1289,14 @@ describe('composeServerlessDefinition', () => { const result = await composeServerlessDefinition(appDefinition); - expect(result.provider.environment.STAGE).toBe('${opt:stage, "dev"}'); - expect(result.provider.environment.AWS_NODEJS_CONNECTION_REUSE_ENABLED).toBe(1); + expect(result.provider.environment).toEqual( + expect.objectContaining({ + STAGE: '${self:provider.stage}', + FRIGG_STAGE: '${self:provider.stage}', + FRIGG_STACK: '${self:service}', + FRIGG_REGION: '${self:provider.region}', + }) + ); }); }); @@ -1656,306 +1353,77 @@ describe('composeServerlessDefinition', () => { }); }); - describe('CRITICAL: NAT Gateway MUST be in PUBLIC Subnet', () => { - it('should NEVER reuse NAT Gateway in private subnet - throw error when selfHeal disabled', async () => { - // Mock NAT Gateway found in PRIVATE subnet (the original bug) - const mockDiscovery = require('./aws-discovery').AWSDiscovery; - const mockInstance = new mockDiscovery(); - mockInstance.discoverResources = jest.fn().mockResolvedValue({ - defaultVpcId: 'vpc-123456', - defaultSecurityGroupId: 'sg-123456', - privateSubnetId1: 'subnet-private1', - privateSubnetId2: 'subnet-private2', - publicSubnetId: 'subnet-public', - existingNatGatewayId: 'nat-in-private', - natGatewayInPrivateSubnet: true, // CRITICAL: NAT is in WRONG subnet - existingElasticIpAllocationId: 'eipalloc-123' - }); - mockDiscovery.mockImplementation(() => mockInstance); - - const appDefinition = { - vpc: { - enable: true, - natGateway: { management: 'createAndManage' }, - selfHeal: false - }, - integrations: [] - }; - - // Should throw error because NAT is in private subnet - await expect(composeServerlessDefinition(appDefinition)) - .rejects - .toThrow('CRITICAL: NAT Gateway is in PRIVATE subnet'); - }); - - it('should create NEW NAT in PUBLIC subnet when existing NAT is in private subnet with selfHeal', async () => { - const mockDiscovery = require('./aws-discovery').AWSDiscovery; - const mockInstance = new mockDiscovery(); - mockInstance.discoverResources = jest.fn().mockResolvedValue({ - defaultVpcId: 'vpc-123456', - defaultSecurityGroupId: 'sg-123456', - privateSubnetId1: 'subnet-private1', - privateSubnetId2: 'subnet-private2', - publicSubnetId: 'subnet-public', - existingNatGatewayId: 'nat-in-private', - natGatewayInPrivateSubnet: true, // NAT is in WRONG subnet - existingElasticIpAllocationId: 'eipalloc-123' - }); - mockDiscovery.mockImplementation(() => mockInstance); - - const appDefinition = { - vpc: { - enable: true, - natGateway: { management: 'createAndManage' }, - selfHeal: true - }, - integrations: [] - }; - - const result = await composeServerlessDefinition(appDefinition); - - // MUST create new NAT Gateway (not reuse the one in private subnet) - expect(result.resources.Resources.FriggNATGateway).toBeDefined(); - - // MUST be placed in PUBLIC subnet - const natSubnet = result.resources.Resources.FriggNATGateway.Properties.SubnetId; - expect(natSubnet).toEqual('subnet-public'); - - // MUST create new EIP (cannot reuse the one associated with wrong NAT) - expect(result.resources.Resources.FriggNATGatewayEIP).toBeDefined(); - }); - - it('should create public subnet for NAT when none exists', async () => { - const mockDiscovery = require('./aws-discovery').AWSDiscovery; - const mockInstance = new mockDiscovery(); - mockInstance.discoverResources = jest.fn().mockResolvedValue({ - defaultVpcId: 'vpc-123456', - defaultSecurityGroupId: 'sg-123456', - privateSubnetId1: 'subnet-private1', - privateSubnetId2: 'subnet-private2', - publicSubnetId: null, // NO PUBLIC SUBNET EXISTS - existingNatGatewayId: null - }); - mockDiscovery.mockImplementation(() => mockInstance); - - const appDefinition = { - vpc: { - enable: true, - natGateway: { management: 'createAndManage' }, - selfHeal: true - }, - integrations: [] - }; - - const result = await composeServerlessDefinition(appDefinition); - - // MUST create public subnet - expect(result.resources.Resources.FriggPublicSubnet).toBeDefined(); - expect(result.resources.Resources.FriggPublicSubnet.Properties.MapPublicIpOnLaunch).toBe(true); - - // NAT Gateway MUST be in the newly created public subnet - expect(result.resources.Resources.FriggNATGateway.Properties.SubnetId) - .toEqual({ Ref: 'FriggPublicSubnet' }); - }); - - it('should reuse CORRECTLY placed NAT Gateway in public subnet', async () => { - const mockDiscovery = require('./aws-discovery').AWSDiscovery; - const mockInstance = new mockDiscovery(); - mockInstance.discoverResources = jest.fn().mockResolvedValue({ - defaultVpcId: 'vpc-123456', - defaultSecurityGroupId: 'sg-123456', - privateSubnetId1: 'subnet-private1', - privateSubnetId2: 'subnet-private2', - publicSubnetId: 'subnet-public', - existingNatGatewayId: 'nat-good', - natGatewayInPrivateSubnet: false, // NAT is CORRECTLY in public subnet - existingElasticIpAllocationId: 'eipalloc-123' - }); - mockDiscovery.mockImplementation(() => mockInstance); - - const appDefinition = { - vpc: { - enable: true, - natGateway: { management: 'createAndManage' }, - selfHeal: true - }, - integrations: [] - }; - - const result = await composeServerlessDefinition(appDefinition); - - // Should NOT create new NAT Gateway (reuse the good one) - expect(result.resources.Resources.FriggNATGateway).toBeUndefined(); - expect(result.resources.Resources.FriggNATGatewayEIP).toBeUndefined(); - - // Should use existing NAT in routes - expect(result.resources.Resources.FriggNATRoute.Properties.NatGatewayId) - .toEqual('nat-good'); - }); - - it('should fix route table associations to prevent NAT misconfiguration', async () => { - const mockDiscovery = require('./aws-discovery').AWSDiscovery; - const mockInstance = new mockDiscovery(); - mockInstance.discoverResources = jest.fn().mockResolvedValue({ - defaultVpcId: 'vpc-123456', - defaultSecurityGroupId: 'sg-123456', - privateSubnetId1: 'subnet-private1', - privateSubnetId2: 'subnet-private2', - publicSubnetId: 'subnet-public', - existingNatGatewayId: 'nat-good', - natGatewayInPrivateSubnet: false - }); - mockDiscovery.mockImplementation(() => mockInstance); - - const appDefinition = { - vpc: { - enable: true, - natGateway: { management: 'discover' }, - selfHeal: true - }, - integrations: [] - }; - - const result = await composeServerlessDefinition(appDefinition); - - // Should create route table to fix associations - expect(result.resources.Resources.FriggLambdaRouteTable).toBeDefined(); - - // Should create NAT route pointing to good NAT - expect(result.resources.Resources.FriggNATRoute).toBeDefined(); - expect(result.resources.Resources.FriggNATRoute.Properties.NatGatewayId) - .toEqual('nat-good'); - - // Should associate private subnets with correct route table - expect(result.resources.Resources.FriggSubnet1RouteAssociation).toBeDefined(); - expect(result.resources.Resources.FriggSubnet2RouteAssociation).toBeDefined(); - }); - - it('should handle EIP already associated error by reusing existing NAT', async () => { - const mockDiscovery = require('./aws-discovery').AWSDiscovery; - const mockInstance = new mockDiscovery(); - mockInstance.discoverResources = jest.fn().mockResolvedValue({ - defaultVpcId: 'vpc-123456', - defaultSecurityGroupId: 'sg-123456', - privateSubnetId1: 'subnet-private1', - privateSubnetId2: 'subnet-private2', - publicSubnetId: 'subnet-public', - existingNatGatewayId: 'nat-existing', - natGatewayInPrivateSubnet: false, // NAT is correctly placed - existingElasticIpAllocationId: 'eipalloc-inuse', - elasticIpAlreadyAssociated: true // EIP is already in use - }); - mockDiscovery.mockImplementation(() => mockInstance); - - const appDefinition = { - vpc: { - enable: true, - natGateway: { management: 'createAndManage' }, - selfHeal: true - }, - integrations: [] - }; - - const result = await composeServerlessDefinition(appDefinition); - - // Should NOT create new NAT or EIP (reuse existing) - expect(result.resources.Resources.FriggNATGateway).toBeUndefined(); - expect(result.resources.Resources.FriggNATGatewayEIP).toBeUndefined(); - - // Should use existing NAT - expect(result.resources.Resources.FriggNATRoute.Properties.NatGatewayId) - .toEqual('nat-existing'); - }); - }); + // 'CRITICAL: NAT Gateway MUST be in PUBLIC Subnet' scenarios moved with the + // NAT logic into VpcBuilder: see domains/networking/vpc-builder.test.js + // ('NAT Gateway handling': 'should create new NAT when existing is in + // private subnet', 'should reuse existing elastic IP allocation', ...). + // The single manually-invoked dbMigrate function was replaced by an async + // migration pipeline (3bae110e): dbMigrationRouter (HTTP) queues jobs on + // DbMigrationQueue for dbMigrationWorker. Details: migration-builder.test.js. describe('Database Migration Lambda', () => { - it('should include dbMigrate function in all deployments', async () => { - const appDefinition = { - name: 'test-app', - integrations: [] - }; - - const result = await composeServerlessDefinition(appDefinition); + it('should include the migration router and worker in all deployments', async () => { + const result = await composeServerlessDefinition({ integrations: [] }); - // Check dbMigrate function exists - expect(result.functions.dbMigrate).toBeDefined(); - expect(result.functions.dbMigrate.handler).toBe( + expect(result.functions.dbMigrationRouter.handler).toBe( + 'node_modules/@friggframework/core/handlers/routers/db-migration.handler' + ); + expect(result.functions.dbMigrationWorker.handler).toBe( 'node_modules/@friggframework/core/handlers/workers/db-migration.handler' ); + expect(result.resources.Resources.DbMigrationQueue).toBeDefined(); + expect(result.resources.Resources.FriggMigrationStatusBucket).toBeDefined(); }); - it('should configure dbMigrate with correct settings', async () => { - const appDefinition = { - integrations: [] - }; - - const result = await composeServerlessDefinition(appDefinition); - - const dbMigrate = result.functions.dbMigrate; - - // Check timeout (5 minutes for long migrations) - expect(dbMigrate.timeout).toBe(300); - - // Check memory allocation (extra for Prisma CLI) - expect(dbMigrate.memorySize).toBe(512); + it('should run migrations one at a time from the queue', async () => { + const result = await composeServerlessDefinition({ integrations: [] }); + const worker = result.functions.dbMigrationWorker; - // Check description - expect(dbMigrate.description).toContain('database migrations'); - expect(dbMigrate.description).toContain('Prisma'); + expect(worker.reservedConcurrency).toBe(1); + expect(worker.timeout).toBe(900); + expect(worker.events).toEqual([ + { + sqs: { + arn: { 'Fn::GetAtt': ['DbMigrationQueue', 'Arn'] }, + batchSize: 1, + }, + }, + ]); }); - it('should not have HTTP events (manual invocation only)', async () => { - const appDefinition = { - integrations: [] - }; - - const result = await composeServerlessDefinition(appDefinition); - - const dbMigrate = result.functions.dbMigrate; + it('should expose the router under /admin/db-migrate', async () => { + const result = await composeServerlessDefinition({ integrations: [] }); + const paths = result.functions.dbMigrationRouter.events.map( + (e) => `${e.httpApi.method} ${e.httpApi.path}` + ); - // Should have no events (manually invoked via AWS CLI) - expect(dbMigrate.events).toBeUndefined(); + expect(paths).toEqual( + expect.arrayContaining([ + 'POST /admin/db-migrate', + 'GET /admin/db-migrate/status', + ]) + ); }); - it('should include dbMigrate even with VPC enabled', async () => { - const appDefinition = { - vpc: { - enable: true, - management: 'discover' - }, - integrations: [] - }; - - const result = await composeServerlessDefinition(appDefinition); - - // dbMigrate should exist with VPC configuration - expect(result.functions.dbMigrate).toBeDefined(); + it('should include the migration functions with VPC enabled', async () => { + const result = await composeServerlessDefinition({ + vpc: { enable: true }, + integrations: [], + }); - // Should have same VPC access as other functions + expect(result.functions.dbMigrationWorker).toBeDefined(); + expect(result.functions.dbMigrationRouter).toBeDefined(); expect(result.provider.vpc).toBeDefined(); }); - it('should include dbMigrate with database configuration', async () => { - const appDefinition = { - vpc: { - enable: true, - management: 'discover' - }, - database: { - postgres: { - enable: true, - management: 'discover' - } - }, - integrations: [] - }; - - const result = await composeServerlessDefinition(appDefinition); + it('should omit the migration pipeline when postgres is explicitly disabled', async () => { + const result = await composeServerlessDefinition({ + database: { postgres: { enable: false }, mongoDB: { enable: true } }, + integrations: [], + }); - // dbMigrate should exist alongside database configuration - expect(result.functions.dbMigrate).toBeDefined(); - expect(result.provider.environment.DB_TYPE).toBe('postgresql'); - expect(result.provider.environment.DATABASE_URL).toBeDefined(); + expect(result.functions.dbMigrationWorker).toBeUndefined(); + expect(result.functions.dbMigrationRouter).toBeUndefined(); }); }); @@ -1984,7 +1452,10 @@ describe('composeServerlessDefinition', () => { integrations: [invalidIntegration] }; - await expect(composeServerlessDefinition(appDefinition)).rejects.toThrow('Invalid integration: missing Definition or name'); + await expectValidationError( + appDefinition, + 'Integration at index 0 is missing Definition or name' + ); }); it('should handle integration with missing name', async () => { @@ -1995,7 +1466,10 @@ describe('composeServerlessDefinition', () => { integrations: [invalidIntegration] }; - await expect(composeServerlessDefinition(appDefinition)).rejects.toThrow('Invalid integration: missing Definition or name'); + await expectValidationError( + appDefinition, + 'Integration at index 0 is missing Definition or name' + ); }); }); }); From 3476f15f8d9759f28a556d92ff7bf13eae9e28a3 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:30:53 -0400 Subject: [PATCH 29/44] test(devtools): replace postgres/integration tests written for the deleted serverless-template Both suites failed to load since the hexagonal refactor (7b36f0f4) removed aws-discovery.js and serverless-template.js. - postgres-config.test.js is rewritten as a composer-level check of the current AuroraBuilder contract: off, external, managed (cluster, instance, snapshot policy, private subnet group, URL-safe secret, DATABASE_URL, IAM, 5432 ingress), custom settings, use-existing, discover found/not found, invalid mode, and skipping Aurora under frigg start. Discovery and the Prisma layer are stubbed. - integration.test.js is deleted: its subject (BuildTimeDiscovery env plumbing into a synchronous serverless-template with ${self:custom.vpc...} strings) no longer exists. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- .../__tests__/postgres-config.test.js | 1076 ++++------------- .../infrastructure/integration.test.js | 381 ------ 2 files changed, 241 insertions(+), 1216 deletions(-) delete mode 100644 packages/devtools/infrastructure/integration.test.js diff --git a/packages/devtools/infrastructure/__tests__/postgres-config.test.js b/packages/devtools/infrastructure/__tests__/postgres-config.test.js index 3eda24a8f..6dc04278c 100644 --- a/packages/devtools/infrastructure/__tests__/postgres-config.test.js +++ b/packages/devtools/infrastructure/__tests__/postgres-config.test.js @@ -1,914 +1,320 @@ /** - * Unit tests for PostgreSQL (Aurora) configuration + * PostgreSQL (Aurora) configuration through the full composer. + * + * AuroraBuilder has its own unit tests (domains/database/aurora-builder.test.js); + * these check that `database.postgres` in an app definition comes out of + * composeServerlessDefinition — the path `frigg deploy` takes — as the right + * CloudFormation resources, provider environment and IAM statements. + * + * AWS discovery is stubbed (no credentials or network needed) and the Prisma + * layer build is stubbed so the test never runs `npm install` into + * packages/devtools/layers/prisma. */ +jest.mock('../domains/shared/resource-discovery', () => ({ + ...jest.requireActual('../domains/shared/resource-discovery'), + gatherDiscoveredResources: jest.fn(), +})); +jest.mock('../domains/shared/utilities/prisma-layer-manager', () => ({ + ensurePrismaLayerExists: jest.fn().mockResolvedValue(undefined), +})); + +const { + gatherDiscoveredResources, +} = require('../domains/shared/resource-discovery'); const { composeServerlessDefinition } = require('../infrastructure-composer'); -const { AWSDiscovery } = require('../aws-discovery'); -const { mockClient } = require('aws-sdk-client-mock'); -const { RDSClient, DescribeDBClustersCommand, DescribeDBSubnetGroupsCommand } = require('@aws-sdk/client-rds'); -const { SecretsManagerClient, ListSecretsCommand, DescribeSecretCommand } = require('@aws-sdk/client-secrets-manager'); -const { EC2Client, DescribeVpcsCommand, DescribeSubnetsCommand, DescribeSecurityGroupsCommand, DescribeRouteTablesCommand } = require('@aws-sdk/client-ec2'); -const { KMSClient, ListAliasesCommand } = require('@aws-sdk/client-kms'); -const { STSClient, GetCallerIdentityCommand } = require('@aws-sdk/client-sts'); - -// Create mocks for AWS SDK clients -const rdsMock = mockClient(RDSClient); -const secretsManagerMock = mockClient(SecretsManagerClient); -const ec2Mock = mockClient(EC2Client); -const kmsMock = mockClient(KMSClient); -const stsMock = mockClient(STSClient); - -describe('PostgreSQL Aurora Configuration', () => { - beforeEach(() => { - // Reset all mocks - rdsMock.reset(); - secretsManagerMock.reset(); - ec2Mock.reset(); - kmsMock.reset(); - stsMock.reset(); - - // Setup default AWS mocks for discovery - ec2Mock.on(DescribeVpcsCommand).resolves({ - Vpcs: [{ VpcId: 'vpc-12345', IsDefault: true }] - }); - ec2Mock.on(DescribeSubnetsCommand).resolves({ - Subnets: [ - { SubnetId: 'subnet-1', AvailabilityZone: 'us-east-1a', MapPublicIpOnLaunch: false }, - { SubnetId: 'subnet-2', AvailabilityZone: 'us-east-1b', MapPublicIpOnLaunch: false } - ] - }); +// An account with a default VPC and two private + two public subnets. +const accountWithDefaultVpc = (overrides = {}) => ({ + defaultVpcId: 'vpc-12345', + vpcCidr: '172.31.0.0/16', + defaultSecurityGroupId: 'sg-12345', + privateSubnetId1: 'subnet-private-1', + privateSubnetId2: 'subnet-private-2', + publicSubnetId1: 'subnet-public-1', + publicSubnetId2: 'subnet-public-2', + defaultRouteTableId: 'rtb-12345', + ...overrides, +}); - ec2Mock.on(DescribeSecurityGroupsCommand).resolves({ - SecurityGroups: [{ GroupId: 'sg-12345', GroupName: 'default' }] - }); +const postgresApp = (postgres, extra = {}) => ({ + name: 'test-app', + integrations: [], + vpc: { enable: true }, + database: { postgres: { enable: true, ...postgres } }, + ...extra, +}); - stsMock.on(GetCallerIdentityCommand).resolves({ - Account: '123456789012' - }); +// The orchestrator logs each validation error, then throws a summary error. +async function expectValidationError(appDefinition, messagePattern) { + const logSpy = jest.spyOn(console, 'log'); + try { + await expect(composeServerlessDefinition(appDefinition)).rejects.toThrow( + 'Infrastructure validation failed' + ); + const logged = logSpy.mock.calls.map((args) => args.join(' ')).join('\n'); + expect(logged).toMatch(messagePattern); + } finally { + logSpy.mockRestore(); + } +} + +describe('PostgreSQL Aurora configuration (composer)', () => { + const originalSkip = process.env.FRIGG_SKIP_AWS_DISCOVERY; - ec2Mock.on(DescribeRouteTablesCommand).resolves({ - RouteTables: [{ RouteTableId: 'rtb-12345', VpcId: 'vpc-12345' }] - }); + beforeEach(() => { + delete process.env.FRIGG_SKIP_AWS_DISCOVERY; + gatherDiscoveredResources.mockResolvedValue(accountWithDefaultVpc()); }); afterEach(() => { jest.clearAllMocks(); - delete process.env.FRIGG_SKIP_AWS_DISCOVERY; + if (originalSkip === undefined) { + delete process.env.FRIGG_SKIP_AWS_DISCOVERY; + } else { + process.env.FRIGG_SKIP_AWS_DISCOVERY = originalSkip; + } }); - describe('configurePostgres()', () => { - test('should skip configuration when postgres not enabled', async () => { - // Skip discovery since postgres not enabled - process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true'; - - const appDefinition = { + describe('when postgres is not configured', () => { + it('adds no Aurora resources or database env vars', async () => { + const definition = await composeServerlessDefinition({ name: 'test-app', integrations: [], - }; - - const definition = await composeServerlessDefinition(appDefinition); + }); - expect(definition.resources.Resources.FriggAuroraCluster).toBeUndefined(); + const resources = definition.resources.Resources; + expect(resources.FriggAuroraCluster).toBeUndefined(); + expect(resources.FriggDBSecret).toBeUndefined(); expect(definition.provider.environment.DATABASE_URL).toBeUndefined(); - expect(definition.provider.environment.DB_TYPE).toBeUndefined(); }); + }); - test('should create Aurora infrastructure in create-new mode', async () => { - // Skip discovery for create-new mode test - process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true'; - const appDefinition = { - name: 'test-app', - integrations: [], - vpc: { enable: true, management: 'create-new' }, - database: { - postgres: { - enable: true, - management: 'create-new', - databaseName: 'test_db', - masterUsername: 'test_admin', - scaling: { - minCapacity: 0.5, - maxCapacity: 1.0, - }, - }, - }, - }; - - const discoveredResources = { - defaultVpcId: 'vpc-12345', - defaultSecurityGroupId: 'sg-12345', - privateSubnetId1: 'subnet-1', - privateSubnetId2: 'subnet-2', - }; - - const definition = await composeServerlessDefinition(appDefinition); - - // Should create Aurora cluster resources - expect(definition.resources.Resources.FriggAuroraCluster).toBeDefined(); - expect(definition.resources.Resources.FriggAuroraCluster.Type).toBe('AWS::RDS::DBCluster'); - expect(definition.resources.Resources.FriggAuroraCluster.Properties.Engine).toBe('aurora-postgresql'); - expect(definition.resources.Resources.FriggAuroraCluster.Properties.DatabaseName).toBe('test_db'); - - // Should create Aurora instance - expect(definition.resources.Resources.FriggAuroraInstance).toBeDefined(); - expect(definition.resources.Resources.FriggAuroraInstance.Properties.DBInstanceClass).toBe('db.serverless'); - - // Should create DB subnet group - expect(definition.resources.Resources.FriggDBSubnetGroup).toBeDefined(); - - // Should create security group - expect(definition.resources.Resources.FriggAuroraSecurityGroup).toBeDefined(); - expect(definition.resources.Resources.FriggAuroraSecurityGroup.Properties.SecurityGroupIngress[0].FromPort).toBe(5432); - - // Should create Secrets Manager secret - expect(definition.resources.Resources.FriggDatabaseSecret).toBeDefined(); - - // Should create secret attachment - expect(definition.resources.Resources.FriggSecretAttachment).toBeDefined(); - - // Should set environment variables - expect(definition.provider.environment.DATABASE_URL).toBeDefined(); - expect(definition.provider.environment.DB_TYPE).toBe('postgresql'); - - // Should add IAM permissions for Secrets Manager - const secretsManagerPolicy = definition.provider.iamRoleStatements.find( - stmt => stmt.Action.includes('secretsmanager:GetSecretValue') - ); - expect(secretsManagerPolicy).toBeDefined(); - }); - - test('should configure Aurora with custom settings', async () => { - // Skip discovery for create-new mode test - process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true'; - - const appDefinition = { - name: 'test-app', - integrations: [], - vpc: { enable: true, management: 'create-new' }, - database: { - postgres: { - enable: true, - management: 'create-new', - engineVersion: '14.6', - scaling: { - minCapacity: 1.0, - maxCapacity: 4.0, - }, - backupRetentionDays: 14, - deletionProtection: false, - enablePerformanceInsights: true, - }, - }, - }; - - const definition = await composeServerlessDefinition(appDefinition); - - const cluster = definition.resources.Resources.FriggAuroraCluster; - expect(cluster.Properties.EngineVersion).toBe('14.6'); - expect(cluster.Properties.ServerlessV2ScalingConfiguration.MinCapacity).toBe(1.0); - expect(cluster.Properties.ServerlessV2ScalingConfiguration.MaxCapacity).toBe(4.0); - expect(cluster.Properties.BackupRetentionPeriod).toBe(14); - expect(cluster.Properties.DeletionProtection).toBe(false); - - const instance = definition.resources.Resources.FriggAuroraInstance; - expect(instance.Properties.EnablePerformanceInsights).toBe(true); - }); - - test('should set deletion protection by default', async () => { - // Skip discovery for create-new mode test - process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true'; - - const appDefinition = { - name: 'test-app', - integrations: [], - vpc: { enable: true, management: 'create-new' }, - database: { - postgres: { - enable: true, - management: 'create-new', - }, - }, - }; - - const definition = await composeServerlessDefinition(appDefinition); - - const cluster = definition.resources.Resources.FriggAuroraCluster; - expect(cluster.Properties.DeletionProtection).toBe(true); - expect(cluster.DeletionPolicy).toBe('Snapshot'); - expect(cluster.UpdateReplacePolicy).toBe('Snapshot'); - }); - - test('should enable CloudWatch Logs exports', async () => { - // Skip discovery for create-new mode test - process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true'; - - const appDefinition = { + describe("management: 'external' (bring your own DATABASE_URL)", () => { + it('creates no Aurora resources and needs no VPC', async () => { + const definition = await composeServerlessDefinition({ name: 'test-app', integrations: [], - vpc: { enable: true, management: 'create-new' }, - database: { - postgres: { - enable: true, - management: 'create-new', - }, - }, - }; - - const definition = await composeServerlessDefinition(appDefinition); - - const cluster = definition.resources.Resources.FriggAuroraCluster; - expect(cluster.Properties.EnableCloudwatchLogsExports).toContain('postgresql'); - }); - - test('should throw error when use-existing without cluster identifier', async () => { - // Skip discovery and mock RDS to return empty clusters - process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true'; + database: { postgres: { enable: true, management: 'external' } }, + }); - const appDefinition = { - name: 'test-app', - integrations: [], - vpc: { enable: true }, - database: { - postgres: { - enable: true, - management: 'use-existing', - // Missing clusterIdentifier - }, - }, - }; - - // Should throw error during configuration - await expect(composeServerlessDefinition(appDefinition)).rejects.toThrow(); + const resourceTypes = Object.values(definition.resources.Resources).map( + (r) => r.Type + ); + expect(resourceTypes).not.toContain('AWS::RDS::DBCluster'); + expect(resourceTypes).not.toContain('AWS::RDS::DBInstance'); + expect(resourceTypes).not.toContain('AWS::EC2::VPC'); + expect(definition.provider.vpc).toBeUndefined(); }); }); - describe('buildEnvironment() with Aurora mappings', () => { - test('should add Aurora environment variables', async () => { - // Skip discovery for create-new mode test - process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true'; + describe("management: 'managed' (create Aurora in the stack)", () => { + let definition; + let resources; - const appDefinition = { - name: 'test-app', - integrations: [], - vpc: { enable: true, management: 'create-new' }, - database: { - postgres: { - enable: true, - management: 'create-new', - }, - }, - }; - - const definition = await composeServerlessDefinition(appDefinition); - - // Should have standard env vars - expect(definition.provider.environment.STAGE).toBe('${opt:stage, "dev"}'); - expect(definition.provider.environment.AWS_NODEJS_CONNECTION_REUSE_ENABLED).toBe(1); - - // Should have DATABASE_URL and DB_TYPE - expect(definition.provider.environment.DATABASE_URL).toBeDefined(); - expect(definition.provider.environment.DB_TYPE).toBe('postgresql'); + beforeEach(async () => { + definition = await composeServerlessDefinition( + postgresApp({ management: 'managed' }) + ); + resources = definition.resources.Resources; }); - test('should not add Aurora env vars when postgres disabled', async () => { - // Skip discovery since postgres not enabled - process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true'; - - const appDefinition = { - name: 'test-app', - integrations: [], - }; - - const definition = await composeServerlessDefinition(appDefinition); + it('creates an Aurora Serverless v2 PostgreSQL cluster and instance', () => { + const cluster = resources.FriggAuroraCluster; + expect(cluster.Type).toBe('AWS::RDS::DBCluster'); + expect(cluster.Properties.Engine).toBe('aurora-postgresql'); + expect(cluster.Properties.Port).toBe(5432); + expect(cluster.Properties.ServerlessV2ScalingConfiguration).toEqual({ + MinCapacity: 0.5, + MaxCapacity: 4, + }); - expect(definition.provider.environment.DATABASE_URL).toBeUndefined(); - expect(definition.provider.environment.DB_TYPE).toBeUndefined(); + const instance = resources.FriggAuroraInstance; + expect(instance.Type).toBe('AWS::RDS::DBInstance'); + expect(instance.Properties.DBInstanceClass).toBe('db.serverless'); + expect(instance.Properties.DBClusterIdentifier).toEqual({ + Ref: 'FriggAuroraCluster', + }); + expect(instance.Properties.PubliclyAccessible).toBe(false); }); - }); - - describe('Database URL construction', () => { - test('should construct DATABASE_URL with Fn::Sub for create-new mode', async () => { - // Skip discovery for create-new mode test - process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true'; - const appDefinition = { - name: 'test-app', - integrations: [], - vpc: { enable: true, management: 'create-new' }, - database: { - postgres: { - enable: true, - management: 'create-new', - databaseName: 'my_db', - }, - }, - }; - - const definition = await composeServerlessDefinition(appDefinition); - - const databaseUrl = definition.provider.environment.DATABASE_URL; - expect(databaseUrl['Fn::Sub']).toBeDefined(); - expect(databaseUrl['Fn::Sub'][0]).toContain('postgresql://'); - expect(databaseUrl['Fn::Sub'][0]).toContain('${Username}'); - expect(databaseUrl['Fn::Sub'][0]).toContain('${Password}'); - expect(databaseUrl['Fn::Sub'][0]).toContain('${Endpoint}'); - expect(databaseUrl['Fn::Sub'][0]).toContain('${Port}'); - expect(databaseUrl['Fn::Sub'][0]).toContain('${DatabaseName}'); - - const substitutions = databaseUrl['Fn::Sub'][1]; - expect(substitutions.Username).toBeDefined(); - expect(substitutions.Password).toBeDefined(); - expect(substitutions.Endpoint).toBeDefined(); - expect(substitutions.Port).toBeDefined(); - expect(substitutions.DatabaseName).toBe('my_db'); + it('snapshots the cluster on stack deletion', () => { + expect(resources.FriggAuroraCluster.DeletionPolicy).toBe('Snapshot'); }); - }); - describe('Secrets Manager integration', () => { - test('should create secret with correct template', async () => { - // Skip discovery for create-new mode test - process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true'; + it('uses sensible defaults for optional settings', () => { + const props = resources.FriggAuroraCluster.Properties; + expect(props.DatabaseName).toBe('frigg'); + expect(props.EngineVersion).toBe('15.13'); + expect(props.BackupRetentionPeriod).toBe(7); + expect(props.PreferredBackupWindow).toBe('03:00-04:00'); - const appDefinition = { - name: 'test-app', - integrations: [], - vpc: { enable: true, management: 'create-new' }, - database: { - postgres: { - enable: true, - management: 'create-new', - masterUsername: 'custom_admin', - }, - }, - }; - - const definition = await composeServerlessDefinition(appDefinition); - - const secret = definition.resources.Resources.FriggDatabaseSecret; - expect(secret.Properties.GenerateSecretString.SecretStringTemplate).toBe( - JSON.stringify({ username: 'custom_admin' }) + const template = JSON.parse( + resources.FriggDBSecret.Properties.GenerateSecretString + .SecretStringTemplate ); - expect(secret.Properties.GenerateSecretString.GenerateStringKey).toBe('password'); - expect(secret.Properties.GenerateSecretString.PasswordLength).toBe(32); + expect(template.username).toBe('postgres'); }); - test('should exclude special characters from password', async () => { - // Skip discovery for create-new mode test - process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true'; - - const appDefinition = { - name: 'test-app', - integrations: [], - vpc: { enable: true, management: 'create-new' }, - database: { - postgres: { - enable: true, - management: 'create-new', - }, - }, - }; - - const definition = await composeServerlessDefinition(appDefinition); - - const secret = definition.resources.Resources.FriggDatabaseSecret; - expect(secret.Properties.GenerateSecretString.ExcludeCharacters).toBe('"@/\\'); + it('places the DB subnet group in the private subnets', () => { + expect(resources.FriggDBSubnetGroup.Type).toBe('AWS::RDS::DBSubnetGroup'); + expect(resources.FriggDBSubnetGroup.Properties.SubnetIds).toEqual([ + 'subnet-private-1', + 'subnet-private-2', + ]); }); - test('should add Frigg tags to secret', async () => { - // Skip discovery for create-new mode test - process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true'; - - const appDefinition = { - name: 'test-app', - integrations: [], - vpc: { enable: true, management: 'create-new' }, - database: { - postgres: { - enable: true, - management: 'create-new', - }, - }, - }; - - const definition = await composeServerlessDefinition(appDefinition); - - const secret = definition.resources.Resources.FriggDatabaseSecret; - const tags = secret.Properties.Tags; - - expect(tags.find(t => t.Key === 'ManagedBy' && t.Value === 'Frigg')).toBeDefined(); - expect(tags.find(t => t.Key === 'Service' && t.Value === '${self:service}')).toBeDefined(); - expect(tags.find(t => t.Key === 'Stage' && t.Value === '${self:provider.stage}')).toBeDefined(); - }); - }); + it('generates credentials in Secrets Manager with a URL-safe password', () => { + const secret = resources.FriggDBSecret; + expect(secret.Type).toBe('AWS::SecretsManager::Secret'); - describe('Security Group configuration', () => { - test('should allow Lambda SG to access Aurora on port 5432', async () => { - // Skip discovery for create-new mode test - process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true'; + const generate = secret.Properties.GenerateSecretString; + expect(generate.GenerateStringKey).toBe('password'); + expect(generate.PasswordLength).toBe(32); + // Characters with special meaning in a connection URL are excluded. + for (const ch of ['"', '@', ':', '/', '?', '#', '%']) { + expect(generate.ExcludeCharacters).toContain(ch); + } - const appDefinition = { - name: 'test-app', - integrations: [], - vpc: { enable: true, management: 'create-new' }, - database: { - postgres: { - enable: true, - management: 'create-new', - }, - }, - }; - - const definition = await composeServerlessDefinition(appDefinition); - - const sg = definition.resources.Resources.FriggAuroraSecurityGroup; - const ingressRule = sg.Properties.SecurityGroupIngress[0]; - - expect(ingressRule.IpProtocol).toBe('tcp'); - expect(ingressRule.FromPort).toBe(5432); - expect(ingressRule.ToPort).toBe(5432); - expect(ingressRule.Description).toContain('PostgreSQL'); + expect(secret.Properties.Tags).toEqual( + expect.arrayContaining([{ Key: 'ManagedBy', Value: 'Frigg' }]) + ); }); - test('should add Frigg tags to security group', async () => { - // Skip discovery for create-new mode test - process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true'; - - const appDefinition = { - name: 'test-app', - integrations: [], - vpc: { enable: true, management: 'create-new' }, - database: { - postgres: { - enable: true, - management: 'create-new', - }, - }, - }; - - const definition = await composeServerlessDefinition(appDefinition); - - const sg = definition.resources.Resources.FriggAuroraSecurityGroup; - const tags = sg.Properties.Tags; + it('sets DATABASE_URL from the cluster endpoint and the secret', () => { + const databaseUrl = definition.provider.environment.DATABASE_URL; + expect(databaseUrl).toBeDefined(); - expect(tags.find(t => t.Key === 'ManagedBy' && t.Value === 'Frigg')).toBeDefined(); + const serialized = JSON.stringify(databaseUrl); + expect(serialized).toContain('postgresql://'); + expect(serialized).toContain('FriggAuroraCluster'); + expect(serialized).toContain('FriggDBSecret'); }); - }); - - describe('IAM Permissions', () => { - test('should add Secrets Manager permissions', async () => { - // Skip discovery for create-new mode test - process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true'; - const appDefinition = { - name: 'test-app', - integrations: [], - vpc: { enable: true, management: 'create-new' }, - database: { - postgres: { - enable: true, - management: 'create-new', - }, - }, - }; - - const definition = await composeServerlessDefinition(appDefinition); - - const secretsManagerPolicy = definition.provider.iamRoleStatements.find( - stmt => stmt.Action.includes('secretsmanager:GetSecretValue') + it('grants Lambda read access to the database secret', () => { + const statement = definition.provider.iamRoleStatements.find( + (s) => + Array.isArray(s.Action) && + s.Action.includes('secretsmanager:GetSecretValue') && + JSON.stringify(s.Resource).includes('FriggDBSecret') ); + expect(statement).toBeDefined(); + expect(statement.Effect).toBe('Allow'); + }); - expect(secretsManagerPolicy).toBeDefined(); - expect(secretsManagerPolicy.Effect).toBe('Allow'); - expect(secretsManagerPolicy.Action).toContain('secretsmanager:GetSecretValue'); - expect(secretsManagerPolicy.Action).toContain('secretsmanager:DescribeSecret'); + it('lets the Lambda security group reach PostgreSQL on 5432', () => { + const rule = resources.FriggAuroraIngressRule; + expect(rule.Type).toBe('AWS::EC2::SecurityGroupIngress'); + expect(rule.Properties).toEqual( + expect.objectContaining({ + IpProtocol: 'tcp', + FromPort: 5432, + ToPort: 5432, + GroupId: { Ref: 'FriggLambdaSecurityGroup' }, + SourceSecurityGroupId: { Ref: 'FriggLambdaSecurityGroup' }, + }) + ); }); }); - describe('Default values', () => { - test('should use sensible defaults for optional parameters', async () => { - // Skip discovery for create-new mode test - process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true'; - - const appDefinition = { - name: 'test-app', - integrations: [], - vpc: { enable: true, management: 'create-new' }, - database: { - postgres: { - enable: true, - management: 'create-new', - // All optional parameters omitted - }, - }, - }; - - const definition = await composeServerlessDefinition(appDefinition); - - const cluster = definition.resources.Resources.FriggAuroraCluster; - expect(cluster.Properties.EngineVersion).toBe('15.3'); - expect(cluster.Properties.DatabaseName).toBe('frigg_db'); - expect(cluster.Properties.ServerlessV2ScalingConfiguration.MinCapacity).toBe(0.5); - expect(cluster.Properties.ServerlessV2ScalingConfiguration.MaxCapacity).toBe(1.0); - expect(cluster.Properties.BackupRetentionPeriod).toBe(7); - expect(cluster.Properties.PreferredBackupWindow).toBe('03:00-04:00'); - expect(cluster.Properties.DeletionProtection).toBe(true); - - const secret = definition.resources.Resources.FriggDatabaseSecret; - const secretTemplate = JSON.parse(secret.Properties.GenerateSecretString.SecretStringTemplate); - expect(secretTemplate.username).toBe('frigg_admin'); - - const instance = definition.resources.Resources.FriggAuroraInstance; - expect(instance.Properties.EnablePerformanceInsights).toBe(false); + it('applies custom Aurora settings', async () => { + const definition = await composeServerlessDefinition( + postgresApp({ + management: 'managed', + engineVersion: '16.4', + database: 'my_db', + username: 'custom_admin', + minCapacity: 1, + maxCapacity: 8, + }) + ); + + const resources = definition.resources.Resources; + const props = resources.FriggAuroraCluster.Properties; + expect(props.EngineVersion).toBe('16.4'); + expect(props.DatabaseName).toBe('my_db'); + expect(props.ServerlessV2ScalingConfiguration).toEqual({ + MinCapacity: 1, + MaxCapacity: 8, }); + expect( + JSON.parse( + resources.FriggDBSecret.Properties.GenerateSecretString + .SecretStringTemplate + ) + ).toEqual({ username: 'custom_admin' }); }); - describe('Validation Tests', () => { - test('should throw error when postgres enabled but VPC disabled', async () => { - // Skip discovery - process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true'; - - const appDefinition = { - name: 'test-app', - integrations: [], - // VPC not enabled - database: { - postgres: { - enable: true, - management: 'create-new', - }, - }, - }; - - await expect(composeServerlessDefinition(appDefinition)).rejects.toThrow( - /Aurora PostgreSQL requires VPC deployment/ + describe("management: 'use-existing'", () => { + it('points the app at the provided endpoint without creating a cluster', async () => { + const definition = await composeServerlessDefinition( + postgresApp({ + management: 'use-existing', + endpoint: 'existing.cluster.us-east-1.rds.amazonaws.com', + }) ); - }); - - test('should throw error when less than 2 private subnets found', async () => { - // Mock discovery to return only 1 subnet - ec2Mock.on(DescribeSubnetsCommand).resolves({ - Subnets: [ - { SubnetId: 'subnet-1', AvailabilityZone: 'us-east-1a', MapPublicIpOnLaunch: false } - ] - }); - rdsMock.on(DescribeDBClustersCommand).resolves({ DBClusters: [] }); - rdsMock.on(DescribeDBSubnetGroupsCommand).resolves({ DBSubnetGroups: [] }); - secretsManagerMock.on(ListSecretsCommand).resolves({ SecretList: [] }); - - const appDefinition = { - name: 'test-app', - integrations: [], - vpc: { enable: true }, // Use default 'discover' mode to trigger subnet validation - database: { - postgres: { - enable: true, - management: 'create-new', - }, - }, - }; - - await expect(composeServerlessDefinition(appDefinition)).rejects.toThrow( - /Aurora PostgreSQL requires at least 2 private subnets/ + expect(definition.resources.Resources.FriggAuroraCluster).toBeUndefined(); + expect(definition.provider.environment.DATABASE_HOST).toBe( + 'existing.cluster.us-east-1.rds.amazonaws.com' ); + expect(definition.provider.environment.DATABASE_PORT).toBe('5432'); }); - test('should throw error when use-existing without clusterIdentifier', async () => { - // Skip discovery - process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true'; - - const appDefinition = { - name: 'test-app', - integrations: [], - vpc: { enable: true, management: 'create-new' }, - database: { - postgres: { - enable: true, - management: 'use-existing', - // Missing clusterIdentifier - }, - }, - }; - - await expect(composeServerlessDefinition(appDefinition)).rejects.toThrow( - /clusterIdentifier/ + it('rejects a definition without an endpoint', async () => { + await expectValidationError( + postgresApp({ management: 'use-existing' }), + /database\.postgres\.endpoint is required/ ); }); }); - describe('Discover Mode Tests', () => { - test('should use discovered cluster when found', async () => { - // Mock discovered cluster - rdsMock.on(DescribeDBClustersCommand).resolves({ - DBClusters: [{ - DBClusterIdentifier: 'discovered-cluster', - Engine: 'aurora-postgresql', - Status: 'available', - Endpoint: 'discovered.cluster.us-east-1.rds.amazonaws.com', - Port: 5432, - DatabaseName: 'discovered_db', - DBSubnetGroup: 'discovered-subnet-group', - VpcSecurityGroups: [{ VpcSecurityGroupId: 'sg-discovered' }], - TagList: [ - { Key: 'ManagedBy', Value: 'Frigg' }, - { Key: 'Service', Value: 'test-app' }, - { Key: 'Stage', Value: 'dev' } - ] - }] - }); - - rdsMock.on(DescribeDBSubnetGroupsCommand).resolves({ - DBSubnetGroups: [{ - DBSubnetGroupName: 'discovered-subnet-group', - VpcId: 'vpc-12345', - Subnets: [ - { SubnetIdentifier: 'subnet-1' }, - { SubnetIdentifier: 'subnet-2' } - ] - }] - }); + describe("management: 'discover' (the default)", () => { + it('uses a discovered Aurora cluster instead of creating one', async () => { + gatherDiscoveredResources.mockResolvedValue( + accountWithDefaultVpc({ + auroraClusterEndpoint: + 'discovered.cluster-abc.us-east-1.rds.amazonaws.com', + auroraPort: 5432, + }) + ); - secretsManagerMock.on(ListSecretsCommand).resolves({ - SecretList: [{ - ARN: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:frigg-db-secret', - Name: 'frigg-db-secret', - Tags: [ - { Key: 'ManagedBy', Value: 'Frigg' }, - { Key: 'Service', Value: 'test-app' }, - { Key: 'Stage', Value: 'dev' } - ] - }] - }); + const definition = await composeServerlessDefinition( + postgresApp({ management: 'discover' }) + ); - const appDefinition = { - name: 'test-app', - integrations: [], - vpc: { enable: true }, - database: { - postgres: { - enable: true, - management: 'discover', - }, - }, - }; - - const definition = await composeServerlessDefinition(appDefinition); - - // Should use discovered cluster, not create new one expect(definition.resources.Resources.FriggAuroraCluster).toBeUndefined(); - expect(definition.provider.environment.DATABASE_URL).toBeDefined(); - expect(definition.provider.environment.DATABASE_URL['Fn::Sub']).toBeDefined(); - }); - - test('should create new cluster when none found', async () => { - // Mock no clusters found - rdsMock.on(DescribeDBClustersCommand).resolves({ DBClusters: [] }); - rdsMock.on(DescribeDBSubnetGroupsCommand).resolves({ DBSubnetGroups: [] }); - secretsManagerMock.on(ListSecretsCommand).resolves({ SecretList: [] }); - - const appDefinition = { - name: 'test-app', - integrations: [], - vpc: { enable: true }, - database: { - postgres: { - enable: true, - management: 'discover', - }, - }, - }; - - const definition = await composeServerlessDefinition(appDefinition); - - // Should create new cluster - expect(definition.resources.Resources.FriggAuroraCluster).toBeDefined(); - expect(definition.resources.Resources.FriggAuroraCluster.Type).toBe('AWS::RDS::DBCluster'); + expect(definition.provider.environment.DATABASE_HOST).toBe( + 'discovered.cluster-abc.us-east-1.rds.amazonaws.com' + ); }); - test('should use discovered secret ARN when available', async () => { - // Mock discovered resources - rdsMock.on(DescribeDBClustersCommand).resolves({ - DBClusters: [{ - DBClusterIdentifier: 'discovered-cluster', - Engine: 'aurora-postgresql', - Status: 'available', - Endpoint: 'discovered.cluster.us-east-1.rds.amazonaws.com', - Port: 5432, - DatabaseName: 'discovered_db', - DBSubnetGroup: 'discovered-subnet-group', - VpcSecurityGroups: [{ VpcSecurityGroupId: 'sg-discovered' }], - TagList: [ - { Key: 'ManagedBy', Value: 'Frigg' } - ] - }] - }); - - rdsMock.on(DescribeDBSubnetGroupsCommand).resolves({ - DBSubnetGroups: [{ - DBSubnetGroupName: 'discovered-subnet-group', - VpcId: 'vpc-12345', - Subnets: [ - { SubnetIdentifier: 'subnet-1' }, - { SubnetIdentifier: 'subnet-2' } - ] - }] - }); - - secretsManagerMock.on(ListSecretsCommand).resolves({ - SecretList: [{ - ARN: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:discovered-secret', - Name: 'discovered-secret', - Tags: [ - { Key: 'ManagedBy', Value: 'Frigg' } - ] - }] - }); + it('creates a cluster in the stack when none is discovered', async () => { + const definition = await composeServerlessDefinition( + postgresApp({ management: 'discover' }) + ); - const appDefinition = { - name: 'test-app', - integrations: [], - vpc: { enable: true }, - database: { - postgres: { - enable: true, - management: 'discover', - }, - }, - }; - - const definition = await composeServerlessDefinition(appDefinition); - - // Should reference discovered secret in IAM policy - const secretsManagerPolicy = definition.provider.iamRoleStatements.find( - stmt => stmt.Action.includes('secretsmanager:GetSecretValue') + expect(definition.resources.Resources.FriggAuroraCluster.Type).toBe( + 'AWS::RDS::DBCluster' ); - expect(secretsManagerPolicy).toBeDefined(); - expect(secretsManagerPolicy.Resource).toContain('discovered-secret'); }); }); - describe('Integration Tests', () => { - test('should use discovered cluster endpoint in DATABASE_URL', async () => { - // Mock discovered cluster - rdsMock.on(DescribeDBClustersCommand).resolves({ - DBClusters: [{ - DBClusterIdentifier: 'integration-cluster', - Engine: 'aurora-postgresql', - Status: 'available', - Endpoint: 'integration.cluster.us-east-1.rds.amazonaws.com', - Port: 5432, - DatabaseName: 'integration_db', - DBSubnetGroup: 'integration-subnet-group', - VpcSecurityGroups: [{ VpcSecurityGroupId: 'sg-integration' }], - TagList: [{ Key: 'ManagedBy', Value: 'Frigg' }] - }] - }); - - rdsMock.on(DescribeDBSubnetGroupsCommand).resolves({ - DBSubnetGroups: [{ - DBSubnetGroupName: 'integration-subnet-group', - VpcId: 'vpc-12345', - Subnets: [ - { SubnetIdentifier: 'subnet-1' }, - { SubnetIdentifier: 'subnet-2' } - ] - }] - }); - - secretsManagerMock.on(ListSecretsCommand).resolves({ - SecretList: [{ - ARN: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:integration-secret', - Name: 'integration-secret', - Tags: [{ Key: 'ManagedBy', Value: 'Frigg' }] - }] - }); - - const appDefinition = { - name: 'test-app', - integrations: [], - vpc: { enable: true }, - database: { - postgres: { - enable: true, - management: 'discover', - }, - }, - }; - - const definition = await composeServerlessDefinition(appDefinition); - - // DATABASE_URL should reference discovered endpoint - const databaseUrl = definition.provider.environment.DATABASE_URL; - expect(databaseUrl['Fn::Sub']).toBeDefined(); - expect(databaseUrl['Fn::Sub'][0]).toContain('integration.cluster.us-east-1.rds.amazonaws.com'); - }); - - test('should create Secrets Manager VPC endpoint when Aurora enabled', async () => { - // Skip discovery for create-new mode test - process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true'; - - const appDefinition = { - name: 'test-app', - integrations: [], - vpc: { enable: true, management: 'create-new' }, - database: { - postgres: { - enable: true, - management: 'create-new', - }, - }, - }; - - const definition = await composeServerlessDefinition(appDefinition); - - // Should create Secrets Manager VPC endpoint - // Note: For 'create-new' VPC mode, the resource is named FriggSecretsManagerVPCEndpoint - expect(definition.resources.Resources.FriggSecretsManagerVPCEndpoint).toBeDefined(); - expect(definition.resources.Resources.FriggSecretsManagerVPCEndpoint.Type).toBe('AWS::EC2::VPCEndpoint'); - expect(definition.resources.Resources.FriggSecretsManagerVPCEndpoint.Properties.ServiceName).toContain('secretsmanager'); - }); - - test('should use correct security group source in create-new VPC mode', async () => { - // Skip discovery for create-new mode test - process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true'; - - const appDefinition = { - name: 'test-app', - integrations: [], - vpc: { enable: true, management: 'create-new' }, - database: { - postgres: { - enable: true, - management: 'create-new', - }, - }, - }; - - const definition = await composeServerlessDefinition(appDefinition); - - const sg = definition.resources.Resources.FriggAuroraSecurityGroup; - const ingressRule = sg.Properties.SecurityGroupIngress[0]; - - // Should reference FriggLambdaSecurityGroup (created by VPC) - expect(ingressRule.SourceSecurityGroupId).toEqual({ Ref: 'FriggLambdaSecurityGroup' }); - }); + it('rejects an unknown management mode', async () => { + // 'create-new' was the pre-2.0 spelling of 'managed'. + await expectValidationError( + postgresApp({ management: 'create-new' }), + /Invalid database\.postgres\.management: "create-new"/ + ); + }); - test('should use discovered secret ARN in IAM permissions', async () => { - // Mock discovered resources - rdsMock.on(DescribeDBClustersCommand).resolves({ - DBClusters: [{ - DBClusterIdentifier: 'iam-test-cluster', - Engine: 'aurora-postgresql', - Status: 'available', - Endpoint: 'iam.cluster.us-east-1.rds.amazonaws.com', - Port: 5432, - DatabaseName: 'iam_db', - DBSubnetGroup: 'iam-subnet-group', - VpcSecurityGroups: [{ VpcSecurityGroupId: 'sg-iam' }], - TagList: [{ Key: 'ManagedBy', Value: 'Frigg' }] - }] - }); + it('skips Aurora entirely under `frigg start` (FRIGG_SKIP_AWS_DISCOVERY)', async () => { + process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true'; - rdsMock.on(DescribeDBSubnetGroupsCommand).resolves({ - DBSubnetGroups: [{ - DBSubnetGroupName: 'iam-subnet-group', - VpcId: 'vpc-12345', - Subnets: [ - { SubnetIdentifier: 'subnet-1' }, - { SubnetIdentifier: 'subnet-2' } - ] - }] - }); - - secretsManagerMock.on(ListSecretsCommand).resolves({ - SecretList: [{ - ARN: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:specific-iam-secret-abc123', - Name: 'specific-iam-secret', - Tags: [{ Key: 'ManagedBy', Value: 'Frigg' }] - }] - }); + const definition = await composeServerlessDefinition( + postgresApp({ management: 'managed' }) + ); - const appDefinition = { - name: 'test-app', - integrations: [], - vpc: { enable: true }, - database: { - postgres: { - enable: true, - management: 'discover', - }, - }, - }; - - const definition = await composeServerlessDefinition(appDefinition); - - // IAM policy should reference the specific discovered secret ARN - const secretsManagerPolicy = definition.provider.iamRoleStatements.find( - stmt => stmt.Action.includes('secretsmanager:GetSecretValue') - ); - expect(secretsManagerPolicy).toBeDefined(); - expect(secretsManagerPolicy.Resource).toContain('specific-iam-secret-abc123'); - }); + expect(definition.resources.Resources.FriggAuroraCluster).toBeUndefined(); }); }); diff --git a/packages/devtools/infrastructure/integration.test.js b/packages/devtools/infrastructure/integration.test.js deleted file mode 100644 index 2301b5948..000000000 --- a/packages/devtools/infrastructure/integration.test.js +++ /dev/null @@ -1,381 +0,0 @@ -const fs = require('fs'); -const { composeServerlessDefinition } = require('./serverless-template'); -const { AWSDiscovery } = require('./aws-discovery'); -const { BuildTimeDiscovery } = require('./build-time-discovery'); -const FriggServerlessPlugin = require('../../serverless-plugin/index'); - -// Integration tests for end-to-end AWS discovery and serverless config generation -describe('VPC/KMS/SSM Integration Tests', () => { - let mockAWSDiscovery; - let buildTimeDiscovery; - - const mockAWSResources = { - defaultVpcId: 'vpc-12345678', - defaultSecurityGroupId: 'sg-12345678', - privateSubnetId1: 'subnet-private-1', - privateSubnetId2: 'subnet-private-2', - privateRouteTableId: 'rtb-12345678', - defaultKmsKeyId: 'arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012' - }; - - beforeEach(() => { - // Mock AWSDiscovery to return consistent test data - mockAWSDiscovery = { - discoverResources: jest.fn().mockResolvedValue(mockAWSResources), - findDefaultVpc: jest.fn().mockResolvedValue({ VpcId: mockAWSResources.defaultVpcId }), - findPrivateSubnets: jest.fn().mockResolvedValue([ - { SubnetId: mockAWSResources.privateSubnetId1 }, - { SubnetId: mockAWSResources.privateSubnetId2 } - ]), - findDefaultSecurityGroup: jest.fn().mockResolvedValue({ GroupId: mockAWSResources.defaultSecurityGroupId }), - findPrivateRouteTable: jest.fn().mockResolvedValue({ RouteTableId: mockAWSResources.privateRouteTableId }), - findDefaultKmsKey: jest.fn().mockResolvedValue(mockAWSResources.defaultKmsKeyId) - }; - - jest.doMock('./aws-discovery', () => ({ - AWSDiscovery: jest.fn(() => mockAWSDiscovery) - })); - - buildTimeDiscovery = new BuildTimeDiscovery('us-east-1'); - }); - - afterEach(() => { - jest.clearAllMocks(); - jest.resetModules(); - }); - - describe('End-to-End Serverless Configuration Generation', () => { - it('should generate complete serverless config with VPC, KMS, and SSM enabled', async () => { - const appDefinition = { - name: 'test-frigg-app', - vpc: { enable: true }, - encryption: { fieldLevelEncryptionMethod: 'kms' }, - ssm: { enable: true }, - integrations: [{ - Definition: { - name: 'testIntegration' - } - }] - }; - - // Run AWS discovery - const discoveredResources = await mockAWSDiscovery.discoverResources(); - - // Set environment variables as would happen in build - process.env.AWS_DISCOVERY_VPC_ID = discoveredResources.defaultVpcId; - process.env.AWS_DISCOVERY_SECURITY_GROUP_ID = discoveredResources.defaultSecurityGroupId; - process.env.AWS_DISCOVERY_SUBNET_ID_1 = discoveredResources.privateSubnetId1; - process.env.AWS_DISCOVERY_SUBNET_ID_2 = discoveredResources.privateSubnetId2; - process.env.AWS_DISCOVERY_ROUTE_TABLE_ID = discoveredResources.privateRouteTableId; - process.env.AWS_DISCOVERY_KMS_KEY_ID =discoveredResources.defaultKmsKeyId; - - // Generate serverless configuration - const serverlessConfig = composeServerlessDefinition(appDefinition); - - // Verify VPC configuration - expect(serverlessConfig.provider.vpc).toBe('${self:custom.vpc.${self:provider.stage}}'); - expect(serverlessConfig.custom.vpc).toEqual({ - '${self:provider.stage}': { - securityGroupIds: ['${env:AWS_DISCOVERY_SECURITY_GROUP_ID}'], - subnetIds: [ - '${env:AWS_DISCOVERY_SUBNET_ID_1}', - '${env:AWS_DISCOVERY_SUBNET_ID_2}' - ] - } - }); - - // Verify VPC Endpoint - expect(serverlessConfig.resources.Resources.VPCEndpointS3).toEqual({ - Type: 'AWS::EC2::VPCEndpoint', - Properties: { - VpcId: '${env:AWS_DISCOVERY_VPC_ID}', - ServiceName: 'com.amazonaws.${self:provider.region}.s3', - VpcEndpointType: 'Gateway', - RouteTableIds: ['${env:AWS_DISCOVERY_ROUTE_TABLE_ID}'] - } - }); - - // Verify KMS configuration - expect(serverlessConfig.plugins).toContain('serverless-kms-grants'); - expect(serverlessConfig.provider.environment.KMS_KEY_ARN).toBe('${self:custom.kmsGrants.kmsKeyId}'); - expect(serverlessConfig.custom.kmsGrants).toEqual({ - kmsKeyId: '${env:AWS_DISCOVERY_KMS_KEY_ID}' - }); - - // Verify KMS IAM permissions - const kmsPermission = serverlessConfig.provider.iamRoleStatements.find( - statement => statement.Action.includes('kms:GenerateDataKey') - ); - expect(kmsPermission).toBeDefined(); - - // Verify SSM configuration (enabled without offload markers -> broad grant only) - expect(serverlessConfig.provider.layers).toBeUndefined(); - expect(serverlessConfig.provider.environment.SSM_PARAMETER_PREFIX).toBeUndefined(); - - // Verify SSM IAM permissions - const ssmPermission = serverlessConfig.provider.iamRoleStatements.find( - statement => statement.Action.includes('ssm:GetParameter') - ); - expect(ssmPermission).toBeDefined(); - - // Verify integration resources - expect(serverlessConfig.functions.testIntegration).toBeDefined(); - expect(serverlessConfig.functions.testIntegrationQueueWorker).toBeDefined(); - expect(serverlessConfig.resources.Resources.TestIntegrationQueue).toBeDefined(); - - // Clean up environment - delete process.env.AWS_DISCOVERY_VPC_ID; - delete process.env.AWS_DISCOVERY_SECURITY_GROUP_ID; - delete process.env.AWS_DISCOVERY_SUBNET_ID_1; - delete process.env.AWS_DISCOVERY_SUBNET_ID_2; - delete process.env.AWS_DISCOVERY_ROUTE_TABLE_ID; - delete process.env.AWS_DISCOVERY_KMS_KEY_ID; - }); - - it('should generate config with only VPC enabled', async () => { - const appDefinition = { - name: 'vpc-only-app', - vpc: { enable: true }, - integrations: [] - }; - - process.env.AWS_DISCOVERY_VPC_ID = mockAWSResources.defaultVpcId; - process.env.AWS_DISCOVERY_SECURITY_GROUP_ID = mockAWSResources.defaultSecurityGroupId; - process.env.AWS_DISCOVERY_SUBNET_ID_1 = mockAWSResources.privateSubnetId1; - process.env.AWS_DISCOVERY_SUBNET_ID_2 = mockAWSResources.privateSubnetId2; - process.env.AWS_DISCOVERY_ROUTE_TABLE_ID = mockAWSResources.privateRouteTableId; - - const serverlessConfig = composeServerlessDefinition(appDefinition); - - // Should have VPC config - expect(serverlessConfig.provider.vpc).toBeDefined(); - expect(serverlessConfig.custom.vpc).toBeDefined(); - expect(serverlessConfig.resources.Resources.VPCEndpointS3).toBeDefined(); - - // Should not have KMS config - expect(serverlessConfig.plugins).not.toContain('serverless-kms-grants'); - expect(serverlessConfig.provider.environment.KMS_KEY_ARN).toBeUndefined(); - - // Should not have SSM config - expect(serverlessConfig.provider.layers).toBeUndefined(); - expect(serverlessConfig.provider.environment.SSM_PARAMETER_PREFIX).toBeUndefined(); - - // Clean up - delete process.env.AWS_DISCOVERY_VPC_ID; - delete process.env.AWS_DISCOVERY_SECURITY_GROUP_ID; - delete process.env.AWS_DISCOVERY_SUBNET_ID_1; - delete process.env.AWS_DISCOVERY_SUBNET_ID_2; - delete process.env.AWS_DISCOVERY_ROUTE_TABLE_ID; - }); - - it('should generate config with only KMS enabled', async () => { - const appDefinition = { - name: 'kms-only-app', - encryption: { fieldLevelEncryptionMethod: 'kms' }, - integrations: [] - }; - - process.env.AWS_DISCOVERY_KMS_KEY_ID =mockAWSResources.defaultKmsKeyId; - - const serverlessConfig = composeServerlessDefinition(appDefinition); - - // Should have KMS config - expect(serverlessConfig.plugins).toContain('serverless-kms-grants'); - expect(serverlessConfig.custom.kmsGrants).toBeDefined(); - - // Should not have VPC config - expect(serverlessConfig.provider.vpc).toBeUndefined(); - - // Should not have SSM config - expect(serverlessConfig.provider.layers).toBeUndefined(); - - delete process.env.AWS_DISCOVERY_KMS_KEY_ID; - }); - }); - - describe('Plugin Integration', () => { - it('should trigger AWS discovery through serverless plugin', async () => { - const mockServerless = { - cli: { log: jest.fn() }, - service: { - provider: { - name: 'aws', - region: 'us-east-1', - vpc: '${self:custom.vpc.${self:provider.stage}}' - }, - plugins: ['serverless-kms-grants'], - custom: {}, - functions: {} - }, - processedInput: { commands: [] }, - getProvider: jest.fn(() => ({})), - extendConfiguration: jest.fn() - }; - - const plugin = new FriggServerlessPlugin(mockServerless, { stage: 'test' }); - - // Mock BuildTimeDiscovery - const mockBuildTimeDiscovery = { - preBuildHook: jest.fn().mockResolvedValue(mockAWSResources) - }; - - jest.doMock('./build-time-discovery', () => ({ - BuildTimeDiscovery: jest.fn(() => mockBuildTimeDiscovery) - })); - - // Test the beforePackageInitialize hook - await plugin.beforePackageInitialize(); - - expect(mockBuildTimeDiscovery.preBuildHook).toHaveBeenCalledWith( - expect.objectContaining({ - vpc: { enable: true }, - encryption: { fieldLevelEncryptionMethod: 'kms' } - }), - 'us-east-1' - ); - - expect(mockServerless.cli.log).toHaveBeenCalledWith('AWS discovery completed successfully'); - }); - - it('should handle plugin discovery failure gracefully', async () => { - const mockServerless = { - cli: { log: jest.fn() }, - service: { - provider: { - name: 'aws', - region: 'us-east-1', - vpc: '${self:custom.vpc}' - }, - plugins: [], - custom: {}, - functions: {} - }, - processedInput: { commands: [] }, - getProvider: jest.fn(() => ({})), - extendConfiguration: jest.fn() - }; - - const plugin = new FriggServerlessPlugin(mockServerless, { stage: 'test' }); - - // Mock BuildTimeDiscovery to fail - const mockBuildTimeDiscovery = { - preBuildHook: jest.fn().mockRejectedValue(new Error('AWS API Error')) - }; - - jest.doMock('./build-time-discovery', () => ({ - BuildTimeDiscovery: jest.fn(() => mockBuildTimeDiscovery) - })); - - await plugin.beforePackageInitialize(); - - expect(mockServerless.cli.log).toHaveBeenCalledWith('AWS discovery failed, continuing with deployment...'); - expect(mockServerless.cli.log).toHaveBeenCalledWith('Using fallback values for AWS resources'); - - // Verify fallback values are set - expect(process.env.AWS_DISCOVERY_VPC_ID).toBe('vpc-fallback'); - expect(process.env.AWS_DISCOVERY_KMS_KEY_ID).toBe('arn:aws:kms:*:*:key/*'); - }); - }); - - describe('Template Variable Replacement', () => { - it('should replace environment variable placeholders with actual values', () => { - const template = ` - provider: - vpc: - securityGroupIds: - - \${env:AWS_DISCOVERY_SECURITY_GROUP_ID} - subnetIds: - - \${env:AWS_DISCOVERY_SUBNET_ID_1} - - \${env:AWS_DISCOVERY_SUBNET_ID_2} - environment: - KMS_KEY_ARN: \${env:AWS_DISCOVERY_KMS_KEY_ID} - resources: - VPCEndpoint: - Properties: - VpcId: \${env:AWS_DISCOVERY_VPC_ID} - `; - - // Set environment variables - process.env.AWS_DISCOVERY_VPC_ID = mockAWSResources.defaultVpcId; - process.env.AWS_DISCOVERY_SECURITY_GROUP_ID = mockAWSResources.defaultSecurityGroupId; - process.env.AWS_DISCOVERY_SUBNET_ID_1 = mockAWSResources.privateSubnetId1; - process.env.AWS_DISCOVERY_SUBNET_ID_2 = mockAWSResources.privateSubnetId2; - process.env.AWS_DISCOVERY_KMS_KEY_ID =mockAWSResources.defaultKmsKeyId; - - // In a real deployment, serverless framework would resolve these environment variables - // For testing, we can verify the placeholders are correctly formatted - expect(template).toContain('${env:AWS_DISCOVERY_VPC_ID}'); - expect(template).toContain('${env:AWS_DISCOVERY_SECURITY_GROUP_ID}'); - expect(template).toContain('${env:AWS_DISCOVERY_SUBNET_ID_1}'); - expect(template).toContain('${env:AWS_DISCOVERY_SUBNET_ID_2}'); - expect(template).toContain('${env:AWS_DISCOVERY_KMS_KEY_ID}'); - - // Clean up - delete process.env.AWS_DISCOVERY_VPC_ID; - delete process.env.AWS_DISCOVERY_SECURITY_GROUP_ID; - delete process.env.AWS_DISCOVERY_SUBNET_ID_1; - delete process.env.AWS_DISCOVERY_SUBNET_ID_2; - delete process.env.AWS_DISCOVERY_KMS_KEY_ID; - }); - }); - - describe('Error Scenarios', () => { - it('should handle AWS discovery timeout gracefully', async () => { - const mockFailingDiscovery = { - discoverResources: jest.fn().mockRejectedValue(new Error('Request timeout')) - }; - - jest.doMock('./aws-discovery', () => ({ - AWSDiscovery: jest.fn(() => mockFailingDiscovery) - })); - - const appDefinition = { - vpc: { enable: true }, - integrations: [] - }; - - await expect(buildTimeDiscovery.preBuildHook(appDefinition, 'us-east-1')).rejects.toThrow('Request timeout'); - }); - - it('should handle partial AWS resource discovery', async () => { - const partialResources = { - defaultVpcId: 'vpc-12345678', - defaultSecurityGroupId: 'sg-12345678', - privateSubnetId1: 'subnet-1', - privateSubnetId2: 'subnet-1', // Same subnet used twice - privateRouteTableId: 'rtb-12345678', - defaultKmsKeyId: '*' // Fallback KMS key - }; - - mockAWSDiscovery.discoverResources.mockResolvedValue(partialResources); - - const appDefinition = { - vpc: { enable: true }, - encryption: { fieldLevelEncryptionMethod: 'kms' }, - integrations: [] - }; - - const result = await buildTimeDiscovery.preBuildHook(appDefinition, 'us-east-1'); - - expect(result).toEqual(partialResources); - expect(result.privateSubnetId2).toBe(result.privateSubnetId1); // Should handle single subnet scenario - expect(result.defaultKmsKeyId).toBe('*'); // Should handle KMS fallback - }); - }); - - describe('Multi-Region Support', () => { - it('should support different AWS regions', async () => { - const appDefinition = { - vpc: { enable: true }, - integrations: [] - }; - - const euWestDiscovery = new BuildTimeDiscovery('eu-west-1'); - - await euWestDiscovery.preBuildHook(appDefinition, 'eu-west-1'); - - // Verify that AWSDiscovery was instantiated with correct region - expect(mockAWSDiscovery.discoverResources).toHaveBeenCalled(); - }); - }); -}); \ No newline at end of file From 6a544b30ef1298829306ac9ac744b99679c54cea Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:31:10 -0400 Subject: [PATCH 30/44] test(devtools): align health tests with current monitors, drift shape and reconciliation - Progress monitors: the .rejects matcher was attached after advancing fake timers, so Jest 30 reported an unhandled rejection; attach first. - Drift mocks used camelCase propertyPath; CloudFormation's PropertyDifference is PropertyPath: '/X'. - Follow intentional source changes: Lambda resource-mode reconciliation reports supportsResourceUpdate: true (6a0bba7b); the orphan scan covers EC2, RDS and KMS (6c751882), so all three clients are mocked; import de-dup only warns about duplicates dropped for the same logical ID (6a0bba7b). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- .../mismatch-analyzer-method-name.test.js | 20 ++++++----- .../repair-via-import-use-case.test.js | 26 ++++++++++---- .../run-health-check-use-case.test.js | 20 ++++++----- .../__tests__/import-progress-monitor.test.js | 35 +++++++++---------- .../__tests__/update-progress-monitor.test.js | 14 ++++++-- .../adapters/aws-property-reconciler.test.js | 6 ++-- .../adapters/aws-resource-detector.test.js | 10 ++++++ 7 files changed, 86 insertions(+), 45 deletions(-) diff --git a/packages/devtools/infrastructure/domains/health/application/use-cases/__tests__/mismatch-analyzer-method-name.test.js b/packages/devtools/infrastructure/domains/health/application/use-cases/__tests__/mismatch-analyzer-method-name.test.js index 1be8f296f..edfc96d56 100644 --- a/packages/devtools/infrastructure/domains/health/application/use-cases/__tests__/mismatch-analyzer-method-name.test.js +++ b/packages/devtools/infrastructure/domains/health/application/use-cases/__tests__/mismatch-analyzer-method-name.test.js @@ -84,10 +84,12 @@ describe('MismatchAnalyzer Method Name Bug Fix', () => { driftStatus: 'MODIFIED', propertyDifferences: [ { - propertyPath: 'BucketEncryption.Rules[0].BucketKeyEnabled', - expectedValue: true, - actualValue: false, - differenceType: 'NOT_EQUAL', + // Raw CloudFormation PropertyDifference shape, as passed through by + // AWSStackRepository.getResourceDrift() (PascalCase, slash-separated path). + PropertyPath: '/BucketEncryption/ServerSideEncryptionConfiguration/0/BucketKeyEnabled', + ExpectedValue: 'true', + ActualValue: 'false', + DifferenceType: 'NOT_EQUAL', }, ], }); @@ -144,10 +146,12 @@ describe('MismatchAnalyzer Method Name Bug Fix', () => { actualProperties: { BucketEncryption: { enabled: false } }, propertyDifferences: [ { - propertyPath: 'BucketEncryption.enabled', - expectedValue: true, - actualValue: false, - differenceType: 'NOT_EQUAL', + // Raw CloudFormation PropertyDifference shape, as passed through by + // AWSStackRepository.getResourceDrift() (PascalCase, slash-separated path). + PropertyPath: '/BucketEncryption/enabled', + ExpectedValue: 'true', + ActualValue: 'false', + DifferenceType: 'NOT_EQUAL', }, ], }); diff --git a/packages/devtools/infrastructure/domains/health/application/use-cases/__tests__/repair-via-import-use-case.test.js b/packages/devtools/infrastructure/domains/health/application/use-cases/__tests__/repair-via-import-use-case.test.js index 1a1ff2064..61a3dae70 100644 --- a/packages/devtools/infrastructure/domains/health/application/use-cases/__tests__/repair-via-import-use-case.test.js +++ b/packages/devtools/infrastructure/domains/health/application/use-cases/__tests__/repair-via-import-use-case.test.js @@ -548,7 +548,11 @@ describe('RepairViaImportUseCase - importWithLogicalIdMapping', () => { expect(result.unmappedCount).toBe(2); }); - it('should generate warnings for multiple resources of same type', async () => { + it('should warn about duplicate resources dropped for the same logical ID', async () => { + // Since 6a0bba7b, importWithLogicalIdMapping() de-duplicates candidates + // per logical ID and only warns about the candidates it dropped + // (_checkForMultipleResources(duplicates)). Distinct logical IDs are not + // duplicates and produce no warning. // Arrange const stackIdentifier = { stackName: 'acme-integrations-dev', @@ -588,7 +592,7 @@ describe('RepairViaImportUseCase - importWithLogicalIdMapping', () => { const buildTemplate = { resources: {} }; const deployedTemplate = { resources: {} }; - // Three VPCs mapped + // Three VPCs all mapped to the same logical ID const mappings = [ { logicalId: 'FriggVPC', @@ -598,14 +602,14 @@ describe('RepairViaImportUseCase - importWithLogicalIdMapping', () => { confidence: 'high', }, { - logicalId: 'FriggVPC2', + logicalId: 'FriggVPC', physicalId: 'vpc-456', resourceType: 'AWS::EC2::VPC', matchMethod: 'contained-resources', confidence: 'high', }, { - logicalId: 'FriggVPC3', + logicalId: 'FriggVPC', physicalId: 'vpc-789', resourceType: 'AWS::EC2::VPC', matchMethod: 'tag', @@ -628,13 +632,21 @@ describe('RepairViaImportUseCase - importWithLogicalIdMapping', () => { }); // Assert + // Nothing is referenced by the deployed template, so the first candidate + // is kept and the other two are reported as duplicates. expect(result.success).toBe(true); + expect(result.mappedCount).toBe(1); + expect(result.mappings[0].physicalId).toBe('vpc-123'); + expect(result.duplicatesRemoved).toBe(2); expect(result.warnings).toHaveLength(1); expect(result.warnings[0].type).toBe('MULTIPLE_RESOURCES'); expect(result.warnings[0].resourceType).toBe('AWS::EC2::VPC'); - expect(result.warnings[0].count).toBe(3); - expect(result.warnings[0].message).toContain('Multiple VPCs detected (3)'); - expect(result.warnings[0].resources).toHaveLength(3); + expect(result.warnings[0].count).toBe(2); + expect(result.warnings[0].message).toContain('Multiple VPCs detected (2)'); + expect(result.warnings[0].resources.map((r) => r.physicalId)).toEqual([ + 'vpc-456', + 'vpc-789', + ]); }); it('should include build and deployed template paths in result', async () => { diff --git a/packages/devtools/infrastructure/domains/health/application/use-cases/run-health-check-use-case.test.js b/packages/devtools/infrastructure/domains/health/application/use-cases/run-health-check-use-case.test.js index f4730fa19..046248549 100644 --- a/packages/devtools/infrastructure/domains/health/application/use-cases/run-health-check-use-case.test.js +++ b/packages/devtools/infrastructure/domains/health/application/use-cases/run-health-check-use-case.test.js @@ -133,10 +133,12 @@ describe('RunHealthCheckUseCase', () => { driftStatus: 'MODIFIED', propertyDifferences: [ { - propertyPath: 'Properties.EnableDnsSupport', - expectedValue: true, - actualValue: false, - differenceType: 'NOT_EQUAL', + // Raw CloudFormation PropertyDifference shape, as passed through by + // AWSStackRepository.getResourceDrift() (PascalCase, slash-separated path). + PropertyPath: '/EnableDnsSupport', + ExpectedValue: 'true', + ActualValue: 'false', + DifferenceType: 'NOT_EQUAL', }, ], }); @@ -308,10 +310,12 @@ describe('RunHealthCheckUseCase', () => { driftStatus: 'MODIFIED', propertyDifferences: [ { - propertyPath: 'Properties.EnableDnsSupport', - expectedValue: true, - actualValue: false, - differenceType: 'NOT_EQUAL', + // Raw CloudFormation PropertyDifference shape, as passed through by + // AWSStackRepository.getResourceDrift() (PascalCase, slash-separated path). + PropertyPath: '/EnableDnsSupport', + ExpectedValue: 'true', + ActualValue: 'false', + DifferenceType: 'NOT_EQUAL', }, ], }); diff --git a/packages/devtools/infrastructure/domains/health/domain/services/__tests__/import-progress-monitor.test.js b/packages/devtools/infrastructure/domains/health/domain/services/__tests__/import-progress-monitor.test.js index be4d90c62..0690532f4 100644 --- a/packages/devtools/infrastructure/domains/health/domain/services/__tests__/import-progress-monitor.test.js +++ b/packages/devtools/infrastructure/domains/health/domain/services/__tests__/import-progress-monitor.test.js @@ -412,24 +412,23 @@ describe('ImportProgressMonitor', () => { mockCloudFormationRepository.getStackStatus.mockResolvedValue('IMPORT_IN_PROGRESS'); - // Act & Assert - wrap in expect().rejects to properly handle async rejection - await expect( - (async () => { - const resultPromise = monitor.monitorImport({ - stackIdentifier, - resourceLogicalIds, - onProgress, - }); - - // Advance timers beyond 5 minutes (300,000ms) in chunks - // Simulate 151 polls (302 seconds = 302,000ms) to exceed timeout - for (let i = 0; i < 151; i++) { - await jest.advanceTimersByTimeAsync(2000); - } - - return await resultPromise; - })() - ).rejects.toThrow('Import operation timed out'); + // Act & Assert - attach the rejection handler before advancing the fake + // clock: the monitor rejects while the timers run, and an unobserved + // rejection fails the test under Jest 30. + const resultPromise = monitor.monitorImport({ + stackIdentifier, + resourceLogicalIds, + onProgress, + }); + const assertion = expect(resultPromise).rejects.toThrow('Import operation timed out'); + + // Advance timers beyond 5 minutes (300,000ms) in chunks + // Simulate 151 polls (302 seconds = 302,000ms) to exceed timeout + for (let i = 0; i < 151; i++) { + await jest.advanceTimersByTimeAsync(2000); + } + + await assertion; }); it('should filter events by resourceLogicalIds', async () => { diff --git a/packages/devtools/infrastructure/domains/health/domain/services/__tests__/update-progress-monitor.test.js b/packages/devtools/infrastructure/domains/health/domain/services/__tests__/update-progress-monitor.test.js index 31aea933f..623176c06 100644 --- a/packages/devtools/infrastructure/domains/health/domain/services/__tests__/update-progress-monitor.test.js +++ b/packages/devtools/infrastructure/domains/health/domain/services/__tests__/update-progress-monitor.test.js @@ -302,9 +302,14 @@ describe('UpdateProgressMonitor', () => { onProgress: onProgressCallback, }); + // Attach the rejection handler before advancing the fake clock: the + // monitor rejects while the timers run, and an unobserved rejection + // fails the test under Jest 30. + const assertion = expect(monitorPromise).rejects.toThrow('Update operation failed and rolled back'); + await jest.advanceTimersByTimeAsync(2000); - await expect(monitorPromise).rejects.toThrow('Update operation failed and rolled back'); + await assertion; }); }); @@ -334,10 +339,15 @@ describe('UpdateProgressMonitor', () => { onProgress: onProgressCallback, }); + // Attach the rejection handler before advancing the fake clock: the + // monitor rejects while the timers run, and an unobserved rejection + // fails the test under Jest 30. + const assertion = expect(monitorPromise).rejects.toThrow('Update operation timed out'); + // Advance past 5 minute timeout (300000ms) await jest.advanceTimersByTimeAsync(300000 + 2000); - await expect(monitorPromise).rejects.toThrow('Update operation timed out'); + await assertion; }); }); diff --git a/packages/devtools/infrastructure/domains/health/infrastructure/adapters/aws-property-reconciler.test.js b/packages/devtools/infrastructure/domains/health/infrastructure/adapters/aws-property-reconciler.test.js index c98630e01..c3ae99c83 100644 --- a/packages/devtools/infrastructure/domains/health/infrastructure/adapters/aws-property-reconciler.test.js +++ b/packages/devtools/infrastructure/domains/health/infrastructure/adapters/aws-property-reconciler.test.js @@ -447,7 +447,9 @@ describe('AWSPropertyReconciler', () => { ); expect(strategy.supportsTemplateUpdate).toBe(true); - expect(strategy.supportsResourceUpdate).toBe(false); + // Lambda supports direct resource-mode reconciliation + // (UpdateFunctionConfiguration) since 6a0bba7b; template is still preferred. + expect(strategy.supportsResourceUpdate).toBe(true); expect(strategy.recommendedMode).toBe('template'); expect(strategy.limitations).toContain( 'VpcConfig changes may take several minutes to propagate' @@ -659,7 +661,7 @@ describe('AWSPropertyReconciler', () => { expect(strategy).toEqual({ supportsTemplateUpdate: true, - supportsResourceUpdate: false, + supportsResourceUpdate: true, // resource mode via UpdateFunctionConfiguration recommendedMode: 'template', limitations: expect.arrayContaining([ 'VpcConfig changes may take several minutes to propagate', diff --git a/packages/devtools/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector.test.js b/packages/devtools/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector.test.js index 96eca3cc7..ac1903cf7 100644 --- a/packages/devtools/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector.test.js +++ b/packages/devtools/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector.test.js @@ -441,6 +441,16 @@ describe('AWSResourceDetector', () => { describe('findOrphanedResources', () => { const StackIdentifier = require('../../domain/value-objects/stack-identifier'); + beforeEach(() => { + // findOrphanedResources() scans every supported resource type + // (EC2, RDS and KMS), not just the types in the stack, so every + // client needs an (empty) response; each test overrides the one + // service it is about. + mockEC2Send.mockResolvedValue({}); + mockRDSSend.mockResolvedValue({}); + mockKMSSend.mockResolvedValue({}); + }); + it('should find orphaned RDS DBCluster with frigg:stack tag', async () => { const stackIdentifier = new StackIdentifier({ stackName: 'my-app-prod', From b4d71405d1c0ed767a570ae225202ca026921d33 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:31:10 -0400 Subject: [PATCH 31/44] test(devtools): update discovery and VpcBuilder tests to current behaviour cloudformation-discovery and vpc-builder tests predated 37a354e8 and e6fc7c76: the vpcEndpoints map and dynamodbVpcEndpointId key, the Lambda security group kept separate from the default SG (plus its extra lookup), the DynamoDB endpoint being opt-in, reuse of a discovered default SG, FriggPrivateSubnetNRouteTableAssociation IDs, and defaultVpcId instead of vpcId. build-prisma-layer passed a made-up /workspace path to getMigrationSourcePath, which checks the disk; it now uses an mkdtemp directory and removes it afterwards. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- .../domains/networking/vpc-builder.test.js | 45 +++++++++++++------ .../shared/cloudformation-discovery.test.js | 42 ++++++++++++++--- .../scripts/build-prisma-layer.test.js | 35 ++++++++++++--- 3 files changed, 96 insertions(+), 26 deletions(-) diff --git a/packages/devtools/infrastructure/domains/networking/vpc-builder.test.js b/packages/devtools/infrastructure/domains/networking/vpc-builder.test.js index 6013b32be..aa3ed14dd 100644 --- a/packages/devtools/infrastructure/domains/networking/vpc-builder.test.js +++ b/packages/devtools/infrastructure/domains/networking/vpc-builder.test.js @@ -293,10 +293,10 @@ describe('VpcBuilder', () => { ]); expect(result.resources.FriggPrivateSubnet1).toBeDefined(); expect(result.resources.FriggPrivateSubnet2).toBeDefined(); - // In discover mode, we create FriggLambdaSecurityGroup in the discovered VPC - expect(result.vpcConfig.securityGroupIds).toEqual([{ Ref: 'FriggLambdaSecurityGroup' }]); - expect(result.resources.FriggLambdaSecurityGroup).toBeDefined(); - expect(result.resources.FriggLambdaSecurityGroup.Properties.VpcId).toBe('vpc-discovered'); + // In discover mode with no stack-managed Lambda SG, the resolver reuses + // the discovered default security group instead of creating one (37a354e8) + expect(result.vpcConfig.securityGroupIds).toEqual(['sg-discovered']); + expect(result.resources.FriggLambdaSecurityGroup).toBeUndefined(); }); it('should allow sharing discovered subnets when explicitly configured', async () => { @@ -330,6 +330,8 @@ describe('VpcBuilder', () => { management: 'discover', selfHeal: true, }, + // The DynamoDB gateway endpoint is opt-in since 37a354e8 + database: { dynamodb: { enable: true } }, }; const discoveredResources = { @@ -462,6 +464,8 @@ describe('VpcBuilder', () => { const appDefinition = { vpc: { enable: true }, encryption: { fieldLevelEncryptionMethod: 'kms' }, + // The DynamoDB gateway endpoint is opt-in since 37a354e8 + database: { dynamodb: { enable: true } }, }; // Structured discovery from CloudFormation @@ -522,6 +526,8 @@ describe('VpcBuilder', () => { const appDefinition = { vpc: { enable: true, enableVPCEndpoints: true, selfHeal: true }, encryption: { fieldLevelEncryptionMethod: 'kms' }, + // The DynamoDB gateway endpoint is opt-in since 37a354e8 + database: { dynamodb: { enable: true } }, }; const discoveredResources = { defaultVpcId: 'vpc-123', @@ -815,7 +821,9 @@ describe('VpcBuilder', () => { }; const discoveredResources = { - vpcId: 'vpc-123', + // Discovery reports the VPC as defaultVpcId (e6fc7c76); with no VPC + // the resolver refuses to create one implicitly (37a354e8). + defaultVpcId: 'vpc-123', privateSubnetId1: 'subnet-existing-1', privateSubnetId2: 'subnet-existing-2', natGatewayId: 'nat-existing', @@ -825,9 +833,15 @@ describe('VpcBuilder', () => { const result = await vpcBuilder.build(appDefinition, discoveredResources); + // The builder always emits the new association logical IDs, even when + // the stack still has the old FriggSubnetNRouteAssociation ones, so + // CloudFormation replaces drifted associations (see createNatGatewayRouting). + expect(result.resources.FriggSubnet1RouteAssociation).toBeUndefined(); + expect(result.resources.FriggSubnet2RouteAssociation).toBeUndefined(); + // Verify associations have UpdateReplacePolicy to force recreation - expect(result.resources.FriggSubnet1RouteAssociation.UpdateReplacePolicy).toBe('Delete'); - expect(result.resources.FriggSubnet2RouteAssociation.UpdateReplacePolicy).toBe('Delete'); + expect(result.resources.FriggPrivateSubnet1RouteTableAssociation.UpdateReplacePolicy).toBe('Delete'); + expect(result.resources.FriggPrivateSubnet2RouteTableAssociation.UpdateReplacePolicy).toBe('Delete'); // This forces CloudFormation to delete old associations and create new ones // instead of trying to update them in-place (which doesn't work) @@ -1522,7 +1536,7 @@ describe('VpcBuilder', () => { }); it('should use OLD logical IDs for backwards compatibility (FriggNATRoute, VPCEndpointS3 pattern)', async () => { - // CRITICAL TEST: Real Frontify production stack uses OLD naming convention + // CRITICAL TEST: A real production stack uses OLD naming convention // Stack currently has: FriggNATRoute, VPCEndpointS3, VPCEndpointDynamoDB // We MUST use these same logical IDs to avoid AlreadyExists errors const appDefinition = { @@ -1541,7 +1555,7 @@ describe('VpcBuilder', () => { } }; - // Discovery results matching ACTUAL Frontify production stack + // Discovery results matching an actual production stack const discoveredResources = { fromCloudFormationStack: true, stackName: 'create-frigg-app-production', @@ -1589,10 +1603,13 @@ describe('VpcBuilder', () => { expect(result.resources.FriggNATRoute.Type).toBe('AWS::EC2::Route'); expect(result.resources.FriggPrivateRoute).toBeUndefined(); // Should NOT create new ID - expect(result.resources.FriggSubnet1RouteAssociation).toBeDefined(); - expect(result.resources.FriggSubnet2RouteAssociation).toBeDefined(); - expect(result.resources.FriggPrivateSubnet1RouteTableAssociation).toBeUndefined(); - expect(result.resources.FriggPrivateSubnet2RouteTableAssociation).toBeUndefined(); + // Subnet associations are the exception: the builder deliberately moves + // them to the new logical IDs (with UpdateReplacePolicy: Delete) so + // drifted associations are recreated rather than updated in place. + expect(result.resources.FriggSubnet1RouteAssociation).toBeUndefined(); + expect(result.resources.FriggSubnet2RouteAssociation).toBeUndefined(); + expect(result.resources.FriggPrivateSubnet1RouteTableAssociation).toBeDefined(); + expect(result.resources.FriggPrivateSubnet2RouteTableAssociation).toBeDefined(); expect(result.resources.VPCEndpointS3).toBeDefined(); expect(result.resources.VPCEndpointS3.Type).toBe('AWS::EC2::VPCEndpoint'); @@ -1814,6 +1831,8 @@ describe('VpcBuilder', () => { const appDefinition = { vpc: { enable: true }, encryption: { fieldLevelEncryptionMethod: 'kms' }, + // The DynamoDB gateway endpoint is opt-in since 37a354e8 + database: { dynamodb: { enable: true } }, }; const discoveredResources = { diff --git a/packages/devtools/infrastructure/domains/shared/cloudformation-discovery.test.js b/packages/devtools/infrastructure/domains/shared/cloudformation-discovery.test.js index bd653efa7..5a3138c18 100644 --- a/packages/devtools/infrastructure/domains/shared/cloudformation-discovery.test.js +++ b/packages/devtools/infrastructure/domains/shared/cloudformation-discovery.test.js @@ -164,7 +164,7 @@ describe('CloudFormationDiscovery', () => { expect(result.routeTableId).toBe('rtb-123'); expect(result.vpcEndpointSecurityGroupId).toBe('sg-vpce-123'); expect(result.s3VpcEndpointId).toBe('vpce-s3-123'); - expect(result.dynamoDbVpcEndpointId).toBe('vpce-ddb-123'); + expect(result.dynamodbVpcEndpointId).toBe('vpce-ddb-123'); expect(result.kmsVpcEndpointId).toBe('vpce-kms-123'); expect(result.secretsManagerVpcEndpointId).toBe('vpce-sm-123'); }); @@ -190,6 +190,9 @@ describe('CloudFormationDiscovery', () => { expect(result).toEqual({ fromCloudFormationStack: true, + // _extractFromResources() initialises the structured endpoint map whenever + // the stack has resources (37a354e8) + vpcEndpoints: {}, stackName: 'test-stack', auroraClusterId: 'test-cluster', existingLogicalIds: ['FriggAuroraCluster'], @@ -257,6 +260,9 @@ describe('CloudFormationDiscovery', () => { expect(result).toEqual({ fromCloudFormationStack: true, + // _extractFromResources() initialises the structured endpoint map whenever + // the stack has resources (37a354e8) + vpcEndpoints: {}, stackName: 'test-stack', migrationStatusBucket: 'test-migration-bucket', existingLogicalIds: ['FriggMigrationStatusBucket'], @@ -285,6 +291,9 @@ describe('CloudFormationDiscovery', () => { expect(result).toEqual({ fromCloudFormationStack: true, + // _extractFromResources() initialises the structured endpoint map whenever + // the stack has resources (37a354e8) + vpcEndpoints: {}, stackName: 'test-stack', migrationQueueUrl: 'https://sqs.us-east-1.amazonaws.com/123456789/test-queue', @@ -313,6 +322,9 @@ describe('CloudFormationDiscovery', () => { expect(result).toEqual({ fromCloudFormationStack: true, + // _extractFromResources() initialises the structured endpoint map whenever + // the stack has resources (37a354e8) + vpcEndpoints: {}, stackName: 'test-stack', natGatewayId: 'nat-0123456789', existingLogicalIds: ['FriggNatGateway'], @@ -340,6 +352,9 @@ describe('CloudFormationDiscovery', () => { expect(result).toEqual({ fromCloudFormationStack: true, + // _extractFromResources() initialises the structured endpoint map whenever + // the stack has resources (37a354e8) + vpcEndpoints: {}, stackName: 'test-stack', defaultVpcId: 'vpc-037ec55fe87aec1e7', existingLogicalIds: ['FriggVPC'], @@ -378,6 +393,9 @@ describe('CloudFormationDiscovery', () => { expect(result).toEqual({ fromCloudFormationStack: true, + // _extractFromResources() initialises the structured endpoint map whenever + // the stack has resources (37a354e8) + vpcEndpoints: {}, stackName: 'test-stack', defaultVpcId: 'vpc-123', // VpcBuilder expects 'defaultVpcId' defaultKmsKeyId: 'arn:aws:kms:us-east-1:123456789:key/abc', @@ -406,6 +424,9 @@ describe('CloudFormationDiscovery', () => { expect(result).toEqual({ fromCloudFormationStack: true, + // _extractFromResources() initialises the structured endpoint map whenever + // the stack has resources (37a354e8) + vpcEndpoints: {}, stackName: 'test-stack', }); }); @@ -439,6 +460,11 @@ describe('CloudFormationDiscovery', () => { SecurityGroups: [{ VpcId: 'vpc-123' }], }); + // Mock the VPC's default security group lookup (added in 37a354e8) + mockEC2Client.send.mockResolvedValueOnce({ + SecurityGroups: [{ GroupId: 'sg-default-123' }], + }); + // Mock subnet query mockEC2Client.send.mockResolvedValueOnce({ Subnets: [ @@ -469,9 +495,11 @@ describe('CloudFormationDiscovery', () => { const result = await cfDiscovery.discoverFromStack('test-stack'); + expect(result.defaultVpcId).toBe('vpc-123'); + expect(result.defaultSecurityGroupId).toBe('sg-default-123'); expect(result.privateSubnetId1).toBe('subnet-private-1'); expect(result.privateSubnetId2).toBe('subnet-private-2'); - expect(mockEC2Client.send).toHaveBeenCalledTimes(2); + expect(mockEC2Client.send).toHaveBeenCalledTimes(3); }); it('should handle EC2 subnet query errors gracefully', async () => { @@ -739,7 +767,7 @@ describe('CloudFormationDiscovery', () => { expect(result.vpcEndpoints.s3).toBe('vpce-0352ceac2124c14be'); expect(result.vpcEndpoints.dynamodb).toBe('vpce-0b06c4f631199ea68'); expect(result.s3VpcEndpointId).toBe('vpce-0352ceac2124c14be'); - expect(result.dynamoDbVpcEndpointId).toBe('vpce-0b06c4f631199ea68'); + expect(result.dynamodbVpcEndpointId).toBe('vpce-0b06c4f631199ea68'); // Verify NO VPC/KMS resources (they're external) expect(result.defaultVpcId).toBeUndefined(); @@ -776,7 +804,7 @@ describe('CloudFormationDiscovery', () => { expect(result.vpcEndpoints.s3).toBe('vpce-legacy-s3'); expect(result.vpcEndpoints.dynamodb).toBe('vpce-legacy-ddb'); expect(result.s3VpcEndpointId).toBe('vpce-legacy-s3'); - expect(result.dynamoDbVpcEndpointId).toBe('vpce-legacy-ddb'); + expect(result.dynamodbVpcEndpointId).toBe('vpce-legacy-ddb'); }); it('should extract FriggLambdaSecurityGroup from stack', async () => { @@ -807,7 +835,9 @@ describe('CloudFormationDiscovery', () => { // Lambda security group should be extracted expect(result.lambdaSecurityGroupId).toBe('sg-01002240c6a446202'); - expect(result.defaultSecurityGroupId).toBe('sg-01002240c6a446202'); + // The Lambda SG must not masquerade as the VPC's default SG + // (defaultSecurityGroupId is only ever the VPC default; see 37a354e8). + expect(result.defaultSecurityGroupId).toBeUndefined(); expect(result.existingLogicalIds).toContain( 'FriggLambdaSecurityGroup' ); @@ -899,7 +929,7 @@ describe('CloudFormationDiscovery', () => { describe('existingLogicalIds tracking', () => { it('should track OLD VPC endpoint logical IDs (VPCEndpointS3 pattern) for backwards compatibility', async () => { - // CRITICAL: Frontify production uses OLD naming convention + // CRITICAL: Older production stacks use the OLD naming convention const mockStack = { StackName: 'create-frigg-app-production', Outputs: [], diff --git a/packages/devtools/infrastructure/scripts/build-prisma-layer.test.js b/packages/devtools/infrastructure/scripts/build-prisma-layer.test.js index 4751ae260..d9aa62476 100644 --- a/packages/devtools/infrastructure/scripts/build-prisma-layer.test.js +++ b/packages/devtools/infrastructure/scripts/build-prisma-layer.test.js @@ -143,14 +143,35 @@ describe('getMigrationsPackages()', () => { }); describe('getMigrationSourcePath()', () => { - it('should return correct source path for database type', () => { - const searchPaths = ['/workspace/packages/core']; - const dbType = 'postgresql'; + // getMigrationSourcePath() returns the first search path whose + // prisma-/migrations directory exists on disk, so the test needs a + // real directory rather than a made-up absolute path. + const fs = require('fs'); + const os = require('os'); + const path = require('path'); + let tmpRoot; + + beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'frigg-prisma-layer-')); + }); - const sourcePath = getMigrationSourcePath(searchPaths, dbType); - expect(sourcePath).toBe( - '/workspace/packages/core/prisma-postgresql/migrations' - ); + afterEach(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it('should return the migrations path of the first search path that has one', () => { + const missing = path.join(tmpRoot, 'missing'); + const core = path.join(tmpRoot, 'packages', 'core'); + const expected = path.join(core, 'prisma-postgresql', 'migrations'); + fs.mkdirSync(expected, { recursive: true }); + + const sourcePath = getMigrationSourcePath([missing, core], 'postgresql'); + expect(sourcePath).toBe(expected); + }); + + it('should return null when no search path has migrations for the database type', () => { + const sourcePath = getMigrationSourcePath([tmpRoot], 'postgresql'); + expect(sourcePath).toBeNull(); }); }); From 704ca4c742325e751d532a888f5ec0c1491d3f25 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:31:10 -0400 Subject: [PATCH 32/44] test(devtools): fix the frigg-cli suites and drop the obsolete index test - doctor: a jest import from @jest/globals collided with babel-jest-hoist under Jest 30; a date test built UTC dates and compared local text, so it failed west of UTC. Now local-time dates. - deploy: a whole-module fs mock broke tmp (reads fs.constants at load) and the mock osls child never exited, hanging the async flow. fs is partially mocked, the post-deploy doctor call is stubbed (no AWS), and the mock child exits. - ui: the command waits 2 s for dev servers and the test slept another 2-3 s per case (42 s suite). Fake timers: 0.15 s. - install: the git identity was set on Jest's sandboxed process.env, which never reaches the git process commitChanges spawns, so commits failed on runners with no global git user (Ubuntu CI). The test sets user.name/user.email/commit.gpgsign in its temp repo. - dependencies: devtools is a peer dependency of frigg-cli since 14236100, not a runtime dependency. - index.test.js deleted: requiring the CLI entry parses process.argv and exits, crashing the Jest worker, and it asserted the pre-#639 install flow that install.test.js now covers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- .../__tests__/unit/commands/deploy.test.js | 54 +++--- .../__tests__/unit/commands/doctor.test.js | 16 +- .../__tests__/unit/commands/install.test.js | 7 + .../__tests__/unit/commands/ui.test.js | 61 +++---- .../__tests__/unit/dependencies.test.js | 8 +- packages/devtools/frigg-cli/index.test.js | 158 ------------------ 6 files changed, 84 insertions(+), 220 deletions(-) delete mode 100644 packages/devtools/frigg-cli/index.test.js diff --git a/packages/devtools/frigg-cli/__tests__/unit/commands/deploy.test.js b/packages/devtools/frigg-cli/__tests__/unit/commands/deploy.test.js index b9e14540f..3abee5777 100644 --- a/packages/devtools/frigg-cli/__tests__/unit/commands/deploy.test.js +++ b/packages/devtools/frigg-cli/__tests__/unit/commands/deploy.test.js @@ -14,10 +14,18 @@ jest.mock('child_process', () => ({ spawn: jest.fn() })); +// Partial mock: only existsSync is stubbed, the rest of fs stays real (a +// bare { existsSync } mock breaks any dependency that reads fs.constants). jest.mock('fs', () => ({ + ...jest.requireActual('fs'), existsSync: jest.fn() })); +// The post-deployment health check calls the doctor command (real AWS). +jest.mock('../../../doctor-command', () => ({ + doctorCommand: jest.fn().mockResolvedValue(undefined) +})); + // Require after mocks const { spawn } = require('child_process'); const fs = require('fs'); @@ -28,6 +36,8 @@ describe('CLI Command: deploy', () => { let consoleWarnSpy; let consoleErrorSpy; let mockChildProcess; + let childOutcome; + let exitSpy; beforeEach(() => { jest.clearAllMocks(); @@ -37,10 +47,18 @@ describe('CLI Command: deploy', () => { consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); - // Mock child process + // Mock child process. deployCommand awaits the child's exit, so the mock + // emits `childOutcome` (default: a clean exit) once its handler is wired. + childOutcome = { event: 'close', value: 0 }; mockChildProcess = { - on: jest.fn() + on: jest.fn((event, handler) => { + if (event === childOutcome.event) { + setImmediate(() => handler(childOutcome.value)); + } + return mockChildProcess; + }) }; + exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {}); // Mock successful spawn by default spawn.mockReturnValue(mockChildProcess); @@ -53,6 +71,7 @@ describe('CLI Command: deploy', () => { consoleLogSpy.mockRestore(); consoleWarnSpy.mockRestore(); consoleErrorSpy.mockRestore(); + exitSpy.mockRestore(); }); describe('Success Cases', () => { @@ -278,43 +297,30 @@ describe('CLI Command: deploy', () => { }); describe('Error Handling', () => { - it('should log error when spawn fails', async () => { - const testError = new Error('Spawn failed'); - - await deployCommand({ stage: 'dev' }); + it('should log and reject when spawn fails', async () => { + childOutcome = { event: 'error', value: new Error('Spawn failed') }; - // Simulate error event - const errorHandler = mockChildProcess.on.mock.calls.find( - call => call[0] === 'error' - )[1]; - errorHandler(testError); + await expect(deployCommand({ stage: 'dev' })).rejects.toThrow('Spawn failed'); expect(consoleErrorSpy).toHaveBeenCalledWith('Error executing command: Spawn failed'); }); - it('should log when child process exits with non-zero code', async () => { - await deployCommand({ stage: 'dev' }); + it('should log and exit with the code when child process exits non-zero', async () => { + childOutcome = { event: 'close', value: 1 }; - // Simulate close event with error code - const closeHandler = mockChildProcess.on.mock.calls.find( - call => call[0] === 'close' - )[1]; - closeHandler(1); + await deployCommand({ stage: 'dev' }); expect(consoleLogSpy).toHaveBeenCalledWith('Child process exited with code 1'); + expect(exitSpy).toHaveBeenCalledWith(1); }); it('should NOT log when child process exits with zero code', async () => { await deployCommand({ stage: 'dev' }); - // Simulate close event with success code - const closeHandler = mockChildProcess.on.mock.calls.find( - call => call[0] === 'close' - )[1]; - closeHandler(0); - // Should not log exit message for success expect(consoleLogSpy).not.toHaveBeenCalledWith('Child process exited with code 0'); + expect(exitSpy).not.toHaveBeenCalled(); + expect(consoleLogSpy).toHaveBeenCalledWith('\n✓ Deployment completed successfully!'); }); }); }); diff --git a/packages/devtools/frigg-cli/__tests__/unit/commands/doctor.test.js b/packages/devtools/frigg-cli/__tests__/unit/commands/doctor.test.js index 9518c6eea..bf7322110 100644 --- a/packages/devtools/frigg-cli/__tests__/unit/commands/doctor.test.js +++ b/packages/devtools/frigg-cli/__tests__/unit/commands/doctor.test.js @@ -3,7 +3,9 @@ * Tests stack listing, selection, and health check orchestration */ -const { describe, test, expect, jest, beforeEach } = require('@jest/globals'); +// `jest` is the injected global: destructuring it from @jest/globals collides +// with the binding babel-plugin-jest-hoist (Jest 30) injects into the module. +const { describe, test, expect, beforeEach } = require('@jest/globals'); describe('Doctor Command - Stack Listing and Selection', () => { let mockCloudFormationClient; @@ -138,25 +140,27 @@ describe('Doctor Command - Stack Listing and Selection', () => { { name: 'production-stack', status: 'UPDATE_COMPLETE', - createdTime: new Date('2024-01-15'), - updatedTime: new Date('2024-10-20'), + // Local-time dates: an ISO date string is UTC midnight, + // which renders as the previous day west of UTC. + createdTime: new Date(2024, 0, 15), + updatedTime: new Date(2024, 9, 20), }, { name: 'dev-stack', status: 'CREATE_COMPLETE', - createdTime: new Date('2024-10-01'), + createdTime: new Date(2024, 9, 1), updatedTime: null, }, ]; const expectedChoices = [ { - name: '✓ production-stack (UPDATE_COMPLETE) - Updated: 10/20/2024', + name: `✓ production-stack (UPDATE_COMPLETE) - Updated: ${mockStacks[0].updatedTime.toLocaleDateString()}`, value: 'production-stack', description: 'Status: UPDATE_COMPLETE', }, { - name: '✓ dev-stack (CREATE_COMPLETE) - Created: 10/1/2024', + name: `✓ dev-stack (CREATE_COMPLETE) - Created: ${mockStacks[1].createdTime.toLocaleDateString()}`, value: 'dev-stack', description: 'Status: CREATE_COMPLETE', }, diff --git a/packages/devtools/frigg-cli/__tests__/unit/commands/install.test.js b/packages/devtools/frigg-cli/__tests__/unit/commands/install.test.js index 109f2c5d0..cfbae1a60 100644 --- a/packages/devtools/frigg-cli/__tests__/unit/commands/install.test.js +++ b/packages/devtools/frigg-cli/__tests__/unit/commands/install.test.js @@ -621,6 +621,13 @@ describe('commitChanges', () => { it('commits only the given files and leaves other staged changes alone', () => { const dir = makeTmpDir(); git(['init', '-q'], dir); + // commitChanges spawns git without an explicit env, so it sees the + // real process environment, not Jest's sandboxed process.env: the + // GIT_* identity set above never reaches it. Give the repo its own + // identity so the commit works on CI runners with no git user. + git(['config', 'user.name', 'Test'], dir); + git(['config', 'user.email', 'test@example.com'], dir); + git(['config', 'commit.gpgsign', 'false'], dir); fs.writeFileSync(path.join(dir, 'package.json'), '{}'); fs.writeFileSync(path.join(dir, 'unrelated.txt'), 'wip'); git(['add', 'unrelated.txt'], dir); diff --git a/packages/devtools/frigg-cli/__tests__/unit/commands/ui.test.js b/packages/devtools/frigg-cli/__tests__/unit/commands/ui.test.js index a57378e7a..d5f96c2f7 100644 --- a/packages/devtools/frigg-cli/__tests__/unit/commands/ui.test.js +++ b/packages/devtools/frigg-cli/__tests__/unit/commands/ui.test.js @@ -33,6 +33,17 @@ const { } = require('../../../utils/repo-detection'); const { uiCommand } = require('../../../ui-command'); +// uiCommand waits 2 s for the dev servers and opens the browser 1 s later. +// Drive those timers with fake time instead of sleeping for real (the real +// waits made this suite take ~40 s and tripped the 5 s default timeout). +const STARTUP_AND_BROWSER_DELAY_MS = 3100; + +async function runUi(options) { + const done = uiCommand(options); + await jest.advanceTimersByTimeAsync(STARTUP_AND_BROWSER_DELAY_MS); + return done; +} + describe('CLI Command: ui', () => { let consoleLogSpy; let consoleErrorSpy; @@ -43,6 +54,7 @@ describe('CLI Command: ui', () => { beforeEach(() => { jest.clearAllMocks(); + jest.useFakeTimers(); // Mock console methods consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); @@ -75,6 +87,7 @@ describe('CLI Command: ui', () => { }); afterEach(() => { + jest.useRealTimers(); consoleLogSpy.mockRestore(); consoleErrorSpy.mockRestore(); processExitSpy.mockRestore(); @@ -84,7 +97,7 @@ describe('CLI Command: ui', () => { describe('Repository Detection', () => { it('should use specified repo path when provided', async () => { - await uiCommand({ repo: '/custom/repo', port: 3001, open: false }); + await runUi({ repo: '/custom/repo', port: 3001, open: false }); expect(consoleLogSpy).toHaveBeenCalledWith( expect.stringContaining('Using specified repository') @@ -92,7 +105,7 @@ describe('CLI Command: ui', () => { }); it('should detect current Frigg repository', async () => { - await uiCommand({ port: 3001, open: false }); + await runUi({ port: 3001, open: false }); expect(getCurrentRepositoryInfo).toHaveBeenCalled(); expect(consoleLogSpy).toHaveBeenCalledWith( @@ -107,7 +120,7 @@ describe('CLI Command: ui', () => { currentSubPath: 'packages/backend' }); - await uiCommand({ port: 3001, open: false }); + await runUi({ port: 3001, open: false }); expect(consoleLogSpy).toHaveBeenCalledWith( expect.stringContaining('Currently in subdirectory: packages/backend') @@ -121,7 +134,7 @@ describe('CLI Command: ui', () => { { path: '/repos/frigg-2', name: 'frigg-2' } ]); - await uiCommand({ port: 3001, open: false }); + await runUi({ port: 3001, open: false }); expect(discoverFriggRepositories).toHaveBeenCalled(); expect(consoleLogSpy).toHaveBeenCalledWith( @@ -133,7 +146,7 @@ describe('CLI Command: ui', () => { getCurrentRepositoryInfo.mockResolvedValue(null); discoverFriggRepositories.mockResolvedValue([]); - await uiCommand({ port: 3001, open: false }); + await runUi({ port: 3001, open: false }); expect(consoleLogSpy).toHaveBeenCalledWith( expect.stringContaining('No Frigg repositories found') @@ -144,7 +157,7 @@ describe('CLI Command: ui', () => { describe('Development Mode', () => { it('should spawn backend and frontend in dev mode', async () => { - await uiCommand({ port: 3001, open: false, dev: true }); + await runUi({ port: 3001, open: false, dev: true }); expect(mockProcessManager.spawnProcess).toHaveBeenCalledTimes(2); expect(mockProcessManager.spawnProcess).toHaveBeenCalledWith( @@ -168,7 +181,7 @@ describe('CLI Command: ui', () => { }); it('should use default port 3001', async () => { - await uiCommand({ open: false, dev: true }); + await runUi({ open: false, dev: true }); expect(mockProcessManager.spawnProcess).toHaveBeenCalledWith( expect.any(String), @@ -181,7 +194,7 @@ describe('CLI Command: ui', () => { }); it('should use custom port when specified', async () => { - await uiCommand({ port: 4000, open: false, dev: true }); + await runUi({ port: 4000, open: false, dev: true }); expect(mockProcessManager.spawnProcess).toHaveBeenCalledWith( expect.any(String), @@ -197,10 +210,7 @@ describe('CLI Command: ui', () => { }); it('should print status with correct URLs', async () => { - await uiCommand({ port: 3001, open: false, dev: true }); - - // Wait for startup delay (testing async behavior, not mocks) - await new Promise(resolve => setTimeout(resolve, 2100)); + await runUi({ port: 3001, open: false, dev: true }); // Verify printStatus called with exact URLs expect(mockProcessManager.printStatus).toHaveBeenCalledWith( @@ -211,20 +221,14 @@ describe('CLI Command: ui', () => { }); it('should open browser when open=true', async () => { - await uiCommand({ port: 3001, open: true, dev: true }); - - // Wait for startup + browser delay (testing async behavior, not mocks) - await new Promise(resolve => setTimeout(resolve, 3100)); + await runUi({ port: 3001, open: true, dev: true }); // Verify browser opened with correct URL expect(open).toHaveBeenCalledWith('http://localhost:5173'); }); it('should NOT open browser when open=false', async () => { - await uiCommand({ port: 3001, open: false, dev: true }); - - // Wait for startup delay (testing async behavior, not mocks) - await new Promise(resolve => setTimeout(resolve, 3100)); + await runUi({ port: 3001, open: false, dev: true }); // Verify browser was not opened expect(open).not.toHaveBeenCalled(); @@ -237,7 +241,7 @@ describe('CLI Command: ui', () => { currentSubPath: null }); - await uiCommand({ port: 3001, open: false, dev: true }); + await runUi({ port: 3001, open: false, dev: true }); expect(mockProcessManager.spawnProcess).toHaveBeenCalledWith( expect.any(String), @@ -252,7 +256,7 @@ describe('CLI Command: ui', () => { }); it('should set REPOSITORY_INFO as JSON string with correct structure', async () => { - await uiCommand({ port: 3001, open: false, dev: true }); + await runUi({ port: 3001, open: false, dev: true }); const call = mockProcessManager.spawnProcess.mock.calls[0]; const env = call[3].env; @@ -275,7 +279,7 @@ describe('CLI Command: ui', () => { { path: '/repos/frigg-1', name: 'frigg-1' } ]); - await uiCommand({ port: 3001, open: false, dev: true }); + await runUi({ port: 3001, open: false, dev: true }); const call = mockProcessManager.spawnProcess.mock.calls[0]; const env = call[3].env; @@ -299,7 +303,7 @@ describe('CLI Command: ui', () => { throw error; }); - await uiCommand({ port: 3001, open: false, dev: true }); + await runUi({ port: 3001, open: false, dev: true }); expect(consoleErrorSpy).toHaveBeenCalledWith( expect.stringContaining('Failed to start Management UI'), @@ -316,7 +320,7 @@ describe('CLI Command: ui', () => { throw new Error('Generic startup error'); }); - await uiCommand({ port: 3001, open: false, dev: true }); + await runUi({ port: 3001, open: false, dev: true }); expect(consoleErrorSpy).toHaveBeenCalledWith( expect.stringContaining('Failed to start Management UI'), @@ -328,16 +332,13 @@ describe('CLI Command: ui', () => { describe('Process Management', () => { it('should create ProcessManager instance', async () => { - await uiCommand({ port: 3001, open: false, dev: true }); + await runUi({ port: 3001, open: false, dev: true }); expect(ProcessManager).toHaveBeenCalled(); }); it('should keep process running with stdin.resume', async () => { - await uiCommand({ port: 3001, open: false, dev: true }); - - // Wait for startup delay (testing async behavior, not mocks) - await new Promise(resolve => setTimeout(resolve, 2100)); + await runUi({ port: 3001, open: false, dev: true }); // Verify stdin.resume called to keep process alive expect(stdinResumeSpy).toHaveBeenCalled(); diff --git a/packages/devtools/frigg-cli/__tests__/unit/dependencies.test.js b/packages/devtools/frigg-cli/__tests__/unit/dependencies.test.js index a3b6123d9..76392444a 100644 --- a/packages/devtools/frigg-cli/__tests__/unit/dependencies.test.js +++ b/packages/devtools/frigg-cli/__tests__/unit/dependencies.test.js @@ -51,8 +51,12 @@ describe('frigg-cli dependencies', () => { expect(packageJson.dependencies.commander).toBeDefined(); }); - it('should have @friggframework/devtools for infrastructure', () => { - expect(packageJson.dependencies['@friggframework/devtools']).toBeDefined(); + // frigg-cli ships inside @friggframework/devtools (14236100), so + // devtools is a peer, not a runtime dependency — a hard dependency + // would make the package depend on itself. + it('should declare @friggframework/devtools as a peer dependency for infrastructure', () => { + expect(packageJson.peerDependencies['@friggframework/devtools']).toBeDefined(); + expect(packageJson.dependencies['@friggframework/devtools']).toBeUndefined(); }); }); diff --git a/packages/devtools/frigg-cli/index.test.js b/packages/devtools/frigg-cli/index.test.js deleted file mode 100644 index a75247580..000000000 --- a/packages/devtools/frigg-cli/index.test.js +++ /dev/null @@ -1,158 +0,0 @@ -const { Command } = require('commander'); -const { installCommand } = require('./index'); -const { validatePackageExists } = require('./install-command/validate-package'); -const { findNearestBackendPackageJson, validateBackendPath } = require('@friggframework/core'); -const { installPackage } = require('./install-command/install-package'); -const { createIntegrationFile } = require('./install-command/integration-file'); -const { updateBackendJsFile } = require('./install-command/backend-js'); -const { commitChanges } = require('./install-command/commit-changes'); -const { logInfo, logError } = require('./install-command/logger'); - -describe('CLI Command Tests', () => { - it('should successfully install an API module when all steps complete without errors', async () => { - const mockApiModuleName = 'testModule'; - const mockPackageName = `@friggframework/api-module-${mockApiModuleName}`; - const mockBackendPath = '/mock/backend/path'; - - jest.mock('./install-command/validate-package', () => ({ - validatePackageExists: jest.fn().mockResolvedValue(true), - })); - jest.mock('./utils/backend-path', () => ({ - findNearestBackendPackageJson: jest - .fn() - .mockReturnValue(mockBackendPath), - validateBackendPath: jest.fn().mockReturnValue(true), - })); - jest.mock('./install-command/install-package', () => ({ - installPackage: jest.fn().mockReturnValue(true), - })); - jest.mock('./install-command/integration-file', () => ({ - createIntegrationFile: jest.fn().mockReturnValue(true), - })); - jest.mock('./install-command/backend-js', () => ({ - updateBackendJsFile: jest.fn().mockReturnValue(true), - })); - jest.mock('./install-command/commit-changes', () => ({ - commitChanges: jest.fn().mockReturnValue(true), - })); - jest.mock('./install-command/logger', () => ({ - logInfo: jest.fn(), - logError: jest.fn(), - })); - - const program = new Command(); - program - .command('install ') - .description('Install an API module') - .action(installCommand); - - await program.parseAsync(['node', 'install', mockApiModuleName]); - - expect(validatePackageExists).toHaveBeenCalledWith(mockPackageName); - expect(findNearestBackendPackageJson).toHaveBeenCalled(); - expect(validateBackendPath).toHaveBeenCalledWith(mockBackendPath); - expect(installPackage).toHaveBeenCalledWith( - mockBackendPath, - mockPackageName - ); - expect(createIntegrationFile).toHaveBeenCalledWith( - mockBackendPath, - mockApiModuleName - ); - expect(updateBackendJsFile).toHaveBeenCalledWith( - mockBackendPath, - mockApiModuleName - ); - expect(commitChanges).toHaveBeenCalledWith( - mockBackendPath, - mockApiModuleName - ); - expect(logInfo).toHaveBeenCalledWith( - `Successfully installed ${mockPackageName} and updated the project.` - ); - }); - - it('should log an error and exit with code 1 if the package does not exist', async () => { - const mockExit = jest - .spyOn(process, 'exit') - .mockImplementation(() => {}); - const mockLogError = jest - .spyOn(require('./install-command/logger'), 'logError') - .mockImplementation(() => {}); - const mockValidatePackageExists = jest - .spyOn( - require('./install-command/validate-package'), - 'validatePackageExists' - ) - .mockImplementation(() => { - throw new Error('Package not found'); - }); - - const program = new Command(); - program - .command('install ') - .description('Install an API module') - .action(installCommand); - - await program.parseAsync(['node', 'install', 'nonexistent-package']); - - expect(mockValidatePackageExists).toHaveBeenCalledWith( - '@friggframework/api-module-nonexistent-package' - ); - expect(mockLogError).toHaveBeenCalledWith( - 'An error occurred:', - expect.any(Error) - ); - expect(mockExit).toHaveBeenCalledWith(1); - - mockExit.mockRestore(); - mockLogError.mockRestore(); - mockValidatePackageExists.mockRestore(); - }); - - it('should log an error and exit with code 1 if the backend path is invalid', async () => { - const mockLogError = jest - .spyOn(require('./install-command/logger'), 'logError') - .mockImplementation(() => {}); - const mockProcessExit = jest - .spyOn(process, 'exit') - .mockImplementation(() => {}); - const mockValidatePackageExists = jest - .spyOn( - require('./install-command/validate-package'), - 'validatePackageExists' - ) - .mockResolvedValue(true); - const mockFindNearestBackendPackageJson = jest - .spyOn( - require('./utils/backend-path'), - 'findNearestBackendPackageJson' - ) - .mockReturnValue('/invalid/path'); - const mockValidateBackendPath = jest - .spyOn(require('./utils/backend-path'), 'validateBackendPath') - .mockImplementation(() => { - throw new Error('Invalid backend path'); - }); - - const program = new Command(); - program - .command('install ') - .description('Install an API module') - .action(installCommand); - - await program.parseAsync(['node', 'install', 'test-module']); - - expect(mockLogError).toHaveBeenCalledWith( - 'An error occurred:', - expect.any(Error) - ); - expect(mockProcessExit).toHaveBeenCalledWith(1); - - mockLogError.mockRestore(); - mockProcessExit.mockRestore(); - mockValidatePackageExists.mockRestore(); - mockFindNearestBackendPackageJson.mockRestore(); - mockValidateBackendPath.mockRestore(); - }); -}); From 52f1374251786694ed8cad0cdaf1d438767fcb55 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:31:10 -0400 Subject: [PATCH 33/44] test(devtools): skip two suites whose subject doesn't exist, with tracking issues - build-time-discovery.test.js (23 tests): build-time-discovery.js requires ./aws-discovery, deleted in 7b36f0f4, and nothing calls the script any more, so the suite can't load its subject. #708 decides between deleting the script and porting it to gatherDiscoveredResources. - orphan-detection-relationship-analysis.test.js (4 tests): red-phase TDD spec for findOrphanedResourcesWithRelationships(), which was never implemented. #709. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- ...an-detection-relationship-analysis.test.js | 9 +++++++- .../scripts/build-time-discovery.test.js | 21 ++++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/devtools/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-relationship-analysis.test.js b/packages/devtools/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-relationship-analysis.test.js index 89e58d302..03a1f194d 100644 --- a/packages/devtools/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-relationship-analysis.test.js +++ b/packages/devtools/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-relationship-analysis.test.js @@ -82,7 +82,14 @@ jest.mock('@aws-sdk/client-kms', () => ({ ListAliasesCommand: jest.fn(), })); -describe('Orphan Detection with Relationship Analysis (TDD)', () => { +// SKIPPED: red-phase TDD spec for a feature that was never implemented. +// AWSResourceDetector has no findOrphanedResourcesWithRelationships() or +// _extractReferencedResourceIds(); the design is in +// domains/health/docs/ORPHAN-DETECTION-ANALYSIS.md. (A template-based variant of +// the reference analysis lives in RepairViaImportUseCase._extractReferencedIdsFromTemplate.) +// Un-skip when the relationship analysis is implemented. +// Tracked in https://github.com/friggframework/frigg/issues/709 +describe.skip('Orphan Detection with Relationship Analysis (TDD)', () => { let detector; let mockEC2Send; let mockRDSSend; diff --git a/packages/devtools/infrastructure/scripts/build-time-discovery.test.js b/packages/devtools/infrastructure/scripts/build-time-discovery.test.js index 0cf9c8090..40a13d49a 100644 --- a/packages/devtools/infrastructure/scripts/build-time-discovery.test.js +++ b/packages/devtools/infrastructure/scripts/build-time-discovery.test.js @@ -1,14 +1,29 @@ const fs = require('fs'); const path = require('path'); -const { BuildTimeDiscovery, runBuildTimeDiscovery } = require('./build-time-discovery'); -const { AWSDiscovery } = require('./aws-discovery'); + +// SKIPPED: BuildTimeDiscovery lazily requires './aws-discovery', which was +// deleted in the hexagonal infrastructure refactor (7b36f0f4). Discovery now +// lives in domains/shared/resource-discovery.js + providers/aws-provider-adapter.js, +// and nothing in devtools, the CLI or the serverless plugin invokes +// build-time-discovery.js / run-discovery.js any more, so this suite cannot load +// its subject. Either port BuildTimeDiscovery to gatherDiscoveredResources() and +// rewrite these tests, or delete the dead module and this suite. +// Tracked in https://github.com/friggframework/frigg/issues/708 +let BuildTimeDiscovery; +let runBuildTimeDiscovery; +let AWSDiscovery; // Mock dependencies jest.mock('fs'); // Mock will be updated when tests are rewritten for new provider architecture // jest.mock('./aws-discovery'); -describe('BuildTimeDiscovery', () => { +describe.skip('BuildTimeDiscovery', () => { + beforeAll(() => { + ({ BuildTimeDiscovery, runBuildTimeDiscovery } = require('./build-time-discovery')); + ({ AWSDiscovery } = require('./aws-discovery')); + }); + let buildTimeDiscovery; let mockAWSDiscovery; const originalEnv = process.env; From 9045bbc0a84c7c07271e8599d1600d58790537a6 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Fri, 23 Oct 2026 23:35:27 -0400 Subject: [PATCH 34/44] fix(test): keep the optional encoding entries in the lockfile The mongodb-memory-server lockfile update dropped node_modules/encoding (an optional peer of node-fetch) along with md5-file. The npm bundled with Node 24.21 rejects that lockfile in `npm ci` ("Missing: encoding@0.1.14 from lock file"), although Node 22's npm accepts it. Restore the two entries exactly as they are on next. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- package-lock.json | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/package-lock.json b/package-lock.json index ea5ee6235..aca5bb489 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16186,6 +16186,29 @@ "node": ">= 0.8" } }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", From 91495fa7fd4cc9e0da6709172c245e97ee06daf4 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Sat, 24 Oct 2026 00:08:17 -0400 Subject: [PATCH 35/44] ci: no password for the throwaway Postgres; npx runs only installed bins The db job's Postgres uses trust auth, so the workflow carries no credential for secret scanners to flag. npx calls use --no-install so CI never fetches a package at run time. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- .github/workflows/frigg-ci.js.yml | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/.github/workflows/frigg-ci.js.yml b/.github/workflows/frigg-ci.js.yml index 266715be7..2a0fe6afa 100644 --- a/.github/workflows/frigg-ci.js.yml +++ b/.github/workflows/frigg-ci.js.yml @@ -52,9 +52,9 @@ jobs: # resolves and every command loads far enough to print its help. - name: CLI smoke test 🧰 run: | - npx frigg --help + npx --no-install frigg --help for cmd in init install start build deploy generate-iam ui db:setup doctor repair ssm auth; do - npx frigg "$cmd" --help > /dev/null + npx --no-install frigg "$cmd" --help > /dev/null done db: @@ -69,7 +69,9 @@ jobs: postgres: image: postgres:16 env: - POSTGRES_PASSWORD: postgres + # Throwaway database reachable only from this job: trust auth, so the + # workflow carries no password for secret scanners to flag. + POSTGRES_HOST_AUTH_METHOD: trust POSTGRES_DB: frigg ports: - 5432:5432 @@ -80,13 +82,13 @@ jobs: --health-retries 10 env: FRIGG_TEST_MONGODB_URL: mongodb://localhost:27017/frigg?replicaSet=rs0 - FRIGG_TEST_POSTGRES_URL: postgresql://postgres:postgres@localhost:5432/frigg?schema=public + FRIGG_TEST_POSTGRES_URL: postgresql://postgres@localhost:5432/frigg?schema=public # Repository parity suites: every adapter (MongoDB, DocumentDB-on-Mongo, # PostgreSQL) against the same cases. INTEGRATION_ENTITIES_PARITY_MONGO_URL: mongodb://localhost:27017/frigg?replicaSet=rs0 - INTEGRATION_ENTITIES_PARITY_POSTGRES_URL: postgresql://postgres:postgres@localhost:5432/frigg?schema=public + INTEGRATION_ENTITIES_PARITY_POSTGRES_URL: postgresql://postgres@localhost:5432/frigg?schema=public QUERY_MAPPINGS_PARITY_MONGO_URL: mongodb://localhost:27017/frigg?replicaSet=rs0 - QUERY_MAPPINGS_PARITY_POSTGRES_URL: postgresql://postgres:postgres@localhost:5432/frigg?schema=public + QUERY_MAPPINGS_PARITY_POSTGRES_URL: postgresql://postgres@localhost:5432/frigg?schema=public MONGOMS_DOWNLOAD_DIR: ${{ github.workspace }}/.cache/mongodb-binaries steps: - name: Check Out Code ⤵️ @@ -126,8 +128,8 @@ jobs: working-directory: packages/core run: | npm run prisma:generate - DATABASE_URL="$FRIGG_TEST_POSTGRES_URL" npx prisma migrate deploy --schema prisma-postgresql/schema.prisma - DATABASE_URL="$FRIGG_TEST_MONGODB_URL" npx prisma db push --skip-generate --schema prisma-mongodb/schema.prisma + DATABASE_URL="$FRIGG_TEST_POSTGRES_URL" npx --no-install prisma migrate deploy --schema prisma-postgresql/schema.prisma + DATABASE_URL="$FRIGG_TEST_MONGODB_URL" npx --no-install prisma db push --skip-generate --schema prisma-mongodb/schema.prisma - name: DB tests 🧪 run: npm run test:db --workspaces --if-present @@ -162,7 +164,7 @@ jobs: # Packages that define lint:fix are the ones with a lint setup. node -e "process.exit(require('./${ws}package.json').scripts?.['lint:fix'] ? 0 : 1)" || continue echo "::group::${ws}" - (cd "$ws" && npx prettier --check --loglevel warn . && npx eslint .) || status=1 + (cd "$ws" && npx --no-install prettier --check --loglevel warn . && npx --no-install eslint .) || status=1 echo "::endgroup::" done exit $status From 325d9af51579c68e720e4927c1f74620ca268790 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Sat, 24 Oct 2026 00:20:41 -0400 Subject: [PATCH 36/44] fix(core): make encryption-registry failures visible and fail closed when deployed Review of #710: loadCustomEncryptionSchema wrapped everything in one catch that logged at debug level, the same handler that hid the broken require fixed in 2a2adffb. One non-string entry in a module's `encryption.credentialFields` (`field.startsWith is not a function`) or an invalid app `encryption.schema` dropped every module field, and those credentials were written in plaintext with nothing in the logs. - credentialFields are validated entry by entry: an invalid entry is skipped with a warning naming the module; valid entries from the same and other modules are still registered. - Only "no backend / no app definition" stays at debug level. Once an app definition is found, an invalid encryption.schema, encryption.disable or module schema is a configuration error: in deployed stages it is logged at error level and thrown as EncryptionSchemaError; in dev/test/local it is a warning and loading continues. - prisma.js rethrows EncryptionSchemaError instead of "Continuing without encryption", so startup fails rather than silently writing plaintext. Other extension-initialisation failures are unchanged here (#663 covers them). New tests fail on the previous source (4 of 5). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- ...encryption-schema-registry-loading.test.js | 123 ++++++++++++++++++ .../encryption/encryption-schema-registry.js | 104 +++++++++++---- .../prisma-encryption-startup.test.js | 65 +++++++++ packages/core/database/prisma.js | 11 +- 4 files changed, 280 insertions(+), 23 deletions(-) create mode 100644 packages/core/database/encryption/__tests__/encryption-schema-registry-loading.test.js create mode 100644 packages/core/database/prisma-encryption-startup.test.js diff --git a/packages/core/database/encryption/__tests__/encryption-schema-registry-loading.test.js b/packages/core/database/encryption/__tests__/encryption-schema-registry-loading.test.js new file mode 100644 index 000000000..b9575f3be --- /dev/null +++ b/packages/core/database/encryption/__tests__/encryption-schema-registry-loading.test.js @@ -0,0 +1,123 @@ +/** + * loadCustomEncryptionSchema against an app definition: bad input must be + * visible, and one bad module entry must not drop every other module field. + */ +const path = require('node:path'); + +// A fresh (virtual) backend directory per app definition, so a module +// registered for one test is never served to another. +let backendCount = 0; +function fakeBackendPaths() { + backendCount += 1; + const dir = path.join(__dirname, `__fake-backend-${backendCount}__`); + return { + packageJson: path.join(dir, 'package.json'), + index: path.join(dir, 'index.js'), + }; +} + +// One shared mock: the registry requires utils lazily, at call time, so a +// per-registry mock instance would not be the one it sees. +const mockFindBackend = jest.fn(); +jest.mock('../../../utils', () => ({ + findNearestBackendPackageJson: (...args) => mockFindBackend(...args), +})); + +function moduleIntegration(credentialFields) { + return { + Definition: { + modules: { + m: { definition: { encryption: { credentialFields } } }, + }, + }, + }; +} + +function loadRegistryWithApp(appDefinition) { + const backend = fakeBackendPaths(); + let registry; + jest.isolateModules(() => { + jest.doMock(backend.index, () => ({ Definition: appDefinition }), { + virtual: true, + }); + mockFindBackend.mockReturnValue(backend.packageJson); + registry = require('../encryption-schema-registry'); + }); + return registry; +} + +describe('loadCustomEncryptionSchema with an app definition', () => { + const originalStage = process.env.STAGE; + let warnSpy; + let errorSpy; + + beforeEach(() => { + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + process.env.STAGE = originalStage; + warnSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + it('skips and warns about an invalid module entry, keeping the valid ones', () => { + process.env.STAGE = 'production'; + const registry = loadRegistryWithApp({ + integrations: [ + moduleIntegration(['webhook_secret', 42]), + moduleIntegration(['signing_key']), + ], + }); + + registry.loadCustomEncryptionSchema(); + + const fields = registry.getEncryptedFields('Credential'); + expect(fields).toContain('data.webhook_secret'); + expect(fields).toContain('data.signing_key'); + expect(warnSpy).toHaveBeenCalledWith( + '[Frigg]', + expect.stringContaining('credentialFields'), + ); + }); + + it('throws an EncryptionSchemaError and logs an error for an invalid app schema in a deployed stage', () => { + process.env.STAGE = 'production'; + const registry = loadRegistryWithApp({ + encryption: { schema: { Credential: { fields: ['data.api_key'] } } }, + integrations: [moduleIntegration(['webhook_secret'])], + }); + + expect(() => registry.loadCustomEncryptionSchema()).toThrow( + registry.EncryptionSchemaError + ); + expect(errorSpy).toHaveBeenCalled(); + }); + + it('warns but keeps going for an invalid app schema in a local stage', () => { + process.env.STAGE = 'dev'; + const registry = loadRegistryWithApp({ + encryption: { schema: { Credential: { fields: ['data.api_key'] } } }, + integrations: [moduleIntegration(['webhook_secret'])], + }); + + expect(() => registry.loadCustomEncryptionSchema()).not.toThrow(); + expect(warnSpy).toHaveBeenCalled(); + expect(registry.getEncryptedFields('Credential')).toContain( + 'data.webhook_secret' + ); + }); + + it('stays quiet when there is no backend at all', () => { + process.env.STAGE = 'production'; + let registry; + jest.isolateModules(() => { + mockFindBackend.mockReturnValue(null); + registry = require('../encryption-schema-registry'); + }); + + expect(() => registry.loadCustomEncryptionSchema()).not.toThrow(); + expect(errorSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/database/encryption/encryption-schema-registry.js b/packages/core/database/encryption/encryption-schema-registry.js index 9b35a0e3c..5b04a7891 100644 --- a/packages/core/database/encryption/encryption-schema-registry.js +++ b/packages/core/database/encryption/encryption-schema-registry.js @@ -146,6 +146,16 @@ function extractCredentialFieldsFromModules(moduleDefinitions) { } for (const field of credentialFields) { + // Validate entry by entry: one bad entry must not drop the + // module's (or any other module's) valid fields. + if (typeof field !== 'string' || field.trim() === '') { + logger.warn( + `Ignoring invalid encryption.credentialFields entry ${JSON.stringify(field)} ` + + `in module "${moduleDef.moduleName || moduleDef.name || 'unknown'}" ` + + '(expected a non-empty string)' + ); + continue; + } const prefixedField = field.startsWith('data.') ? field : `data.${field}`; fields.push(prefixedField); } @@ -225,7 +235,40 @@ function loadModuleEncryptionSchemas(integrations) { * * Used by both Prisma (MongoDB/PostgreSQL) and DocumentDB encryption services. */ +/** + * Raised when an app's encryption configuration is invalid. In deployed + * stages this fails startup instead of silently writing plaintext. + */ +class EncryptionSchemaError extends Error { + constructor(message, { cause } = {}) { + super(message, { cause }); + this.name = 'EncryptionSchemaError'; + } +} + +/** Stages where field-level encryption is bypassed (see prisma.js). */ +function isLocalStage() { + const stage = process.env.STAGE || process.env.NODE_ENV || 'development'; + return ['dev', 'test', 'local'].includes(stage.toLowerCase()); +} + +/** + * An app definition was found but part of its encryption configuration + * could not be applied. Deployed stages fail closed; local stages warn and + * carry on so development isn't blocked. + */ +function reportInvalidEncryptionConfig(what, error) { + const message = `Invalid ${what}: ${error.message}`; + if (isLocalStage()) { + logger.warn(`${message} (ignored in local stage)`); + return; + } + logger.error(message); + throw new EncryptionSchemaError(message, { cause: error }); +} + function loadCustomEncryptionSchema() { + let appDefinition; try { // Lazy require to avoid circular dependency issues const path = require('node:path'); @@ -240,38 +283,54 @@ function loadCustomEncryptionSchema() { const backendIndexPath = path.join(backendDir, 'index.js'); const backendModule = require(backendIndexPath); - const appDefinition = backendModule?.Definition; + appDefinition = backendModule?.Definition; + } catch (error) { + // No loadable backend (tests, standalone usage): custom schema is + // optional, so this stays at debug level. + logger.debug('Could not load custom encryption schema:', error.message); + return; + } - if (!appDefinition) { - return; // No app definition found - } + if (!appDefinition) { + return; // No app definition found + } - // Load app-level custom schema - const customSchema = appDefinition.encryption?.schema; - if (customSchema && Object.keys(customSchema).length > 0) { + // From here an app definition exists, so failures are configuration + // errors and must be visible. + + // Load app-level custom schema + const customSchema = appDefinition.encryption?.schema; + if (customSchema && Object.keys(customSchema).length > 0) { + try { registerCustomSchema(customSchema); + } catch (error) { + reportInvalidEncryptionConfig('app encryption.schema', error); } + } - // Load app-level encryption opt-out — apps can declare fields they - // don't want encrypted on write (decryption on read still works, - // so legacy data remains readable). - const disable = appDefinition.encryption?.disable; - if (disable && Object.keys(disable).length > 0) { + // Load app-level encryption opt-out — apps can declare fields they + // don't want encrypted on write (decryption on read still works, + // so legacy data remains readable). + const disable = appDefinition.encryption?.disable; + if (disable && Object.keys(disable).length > 0) { + try { registerEncryptionOptOut(disable); + } catch (error) { + reportInvalidEncryptionConfig('app encryption.disable', error); } + } - // Load module-level encryption schemas from integrations - const integrations = appDefinition.integrations; - if (integrations && Array.isArray(integrations)) { + // Load module-level encryption schemas from integrations + const integrations = appDefinition.integrations; + if (integrations && Array.isArray(integrations)) { + try { loadModuleEncryptionSchemas(integrations); + } catch (error) { + reportInvalidEncryptionConfig( + 'module encryption.credentialFields', + error + ); } - } catch (error) { - // Silently ignore errors - custom schema is optional - // This handles cases like: - // - Backend package.json not found (tests, standalone usage) - // - No appDefinition defined - // - No custom encryption schema specified - logger.debug('Could not load custom encryption schema:', error.message); } } @@ -406,6 +465,7 @@ function resetCustomSchema() { } module.exports = { + EncryptionSchemaError, CORE_ENCRYPTION_SCHEMA, getEncryptedFields, getFieldsToEncryptOnWrite, diff --git a/packages/core/database/prisma-encryption-startup.test.js b/packages/core/database/prisma-encryption-startup.test.js new file mode 100644 index 000000000..5c7f5e061 --- /dev/null +++ b/packages/core/database/prisma-encryption-startup.test.js @@ -0,0 +1,65 @@ +/** + * Prisma client startup must not "continue without encryption" when the + * app's encryption configuration is invalid: that would write every + * encrypted field in plaintext. Other startup failures keep their current + * behaviour (#663 tightens those separately). + */ +jest.mock('./config', () => ({ + DB_TYPE: 'postgresql', + getDatabaseType: jest.fn(() => 'postgresql'), + PRISMA_LOG_LEVEL: 'error,warn', +})); +jest.mock( + '../generated/prisma-postgresql', + () => ({ + PrismaClient: class { + $extends() { + return this; + } + }, + }), + { virtual: true } +); + +const mockLoadCustomEncryptionSchema = jest.fn(); +jest.mock('./encryption/encryption-schema-registry', () => { + const actual = jest.requireActual('./encryption/encryption-schema-registry'); + return { + ...actual, + loadCustomEncryptionSchema: (...args) => + mockLoadCustomEncryptionSchema(...args), + }; +}); + +const { + EncryptionSchemaError, +} = require('./encryption/encryption-schema-registry'); + +describe('Prisma client startup with encryption enabled', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env.STAGE = 'production'; + process.env.AES_KEY_ID = 'startup-test-key'; + process.env.AES_KEY = '12345678901234567890123456789012'; + delete global._prismaInstance; + jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + delete global._prismaInstance; + jest.restoreAllMocks(); + }); + + it('fails startup on an invalid encryption schema instead of disabling encryption', () => { + mockLoadCustomEncryptionSchema.mockImplementation(() => { + throw new EncryptionSchemaError('Invalid app encryption.schema'); + }); + const { prisma } = require('./prisma'); + + expect(() => prisma.user).toThrow(EncryptionSchemaError); + }); +}); diff --git a/packages/core/database/prisma.js b/packages/core/database/prisma.js index 5042d1608..db9cfb0ae 100644 --- a/packages/core/database/prisma.js +++ b/packages/core/database/prisma.js @@ -1,7 +1,10 @@ const { createEncryptionExtension, } = require('./encryption/prisma-encryption-extension'); -const { loadCustomEncryptionSchema } = require('./encryption/encryption-schema-registry'); +const { + loadCustomEncryptionSchema, + EncryptionSchemaError, +} = require('./encryption/encryption-schema-registry'); const { logger } = require('./encryption/logger'); const { Cryptor } = require('../encrypt/Cryptor'); const config = require('./config'); @@ -125,6 +128,12 @@ const prismaClientSingleton = () => { `Field-level encryption enabled using ${encryptionConfig.method.toUpperCase()}` ); } catch (error) { + // An invalid encryption configuration must fail startup: carrying + // on without the extension would write encrypted fields in + // plaintext. + if (error instanceof EncryptionSchemaError) { + throw error; + } logger.error( 'Failed to initialize encryption extension:', error From 3993629c0a502db76d03bee04990886ca3235bc0 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Sat, 24 Oct 2026 00:23:38 -0400 Subject: [PATCH 37/44] fix(core): keep legacy plaintext and string types intact on the Prisma encryption path Review of #710: registering module credentialFields turns encryption on for fields that have only ever held plaintext, and the Prisma FieldEncryptionService mishandled two kinds of such values. - Colons: _isEncrypted treated any string with 3+ colons as ciphertext. Plaintext like 'a:b:c:d' or a URL with userinfo and a port was never encrypted on write, and on read cryptor.decrypt threw ("Encryption key not found"), failing the whole Credential load. Both paths now share ciphertext-format.isEncryptedEnvelope, the strict check the DocumentDB path already used (exactly 4 base64-alphabet parts, > 50 chars). Values that don't match are plaintext; a real envelope that fails to decrypt still throws, so key errors aren't hidden. - Types: primitives were encrypted as String(value) and read back through JSON.parse, so '123456' came back as the number 123456. Every value is now JSON-encoded before encryption ('123456' -> "\"123456\""), so strings stay strings and numbers stay numbers. Ciphertext written by earlier versions still reads: raw strings that aren't JSON come back unchanged (a legacy raw string that is valid JSON, e.g. '123456', parses as it always did). Older readers also handle the new form, since JSON.parse('"x"') is 'x'. The new extension test (real AES Cryptor) fails on the previous source (3 of 5). The service and extension unit tests' mock cryptors now emit the real envelope shape. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- .../database/documentdb-encryption-service.js | 35 +---- .../prisma-extension-legacy-values.test.js | 102 +++++++++++++++ .../database/encryption/ciphertext-format.js | 24 ++++ .../encryption/field-encryption-service.js | 29 ++--- .../field-encryption-service.test.js | 121 +++++++++--------- .../prisma-encryption-extension.test.js | 79 ++++++------ 6 files changed, 242 insertions(+), 148 deletions(-) create mode 100644 packages/core/database/encryption/__tests__/prisma-extension-legacy-values.test.js create mode 100644 packages/core/database/encryption/ciphertext-format.js diff --git a/packages/core/database/documentdb-encryption-service.js b/packages/core/database/documentdb-encryption-service.js index 68063fffc..e57fae844 100644 --- a/packages/core/database/documentdb-encryption-service.js +++ b/packages/core/database/documentdb-encryption-service.js @@ -1,4 +1,5 @@ const { Cryptor } = require('../encrypt/Cryptor'); +const { isEncryptedEnvelope } = require('./encryption/ciphertext-format'); const { getEncryptedFields, getFieldsToEncryptOnWrite, @@ -295,39 +296,7 @@ class DocumentDBEncryptionService { * _isEncryptedValue({}) // false */ _isEncryptedValue(value) { - // Must be string - if (typeof value !== 'string') { - return false; - } - - // Must have exactly 4 colon-separated parts - const parts = value.split(':'); - if (parts.length !== 4) { - return false; - } - - // Enhanced validation: check for base64 pattern - // This prevents false positives on URLs, connection strings, etc. - const base64Pattern = /^[A-Za-z0-9+/=]+$/; - - // All parts should be base64-encoded - if (!parts.every(part => base64Pattern.test(part))) { - return false; - } - - // Encrypted values should be sufficiently long to be valid - // Real encrypted values from Cryptor are always >50 chars due to envelope encryption format: - // - keyId (base64): ~12 chars minimum - // - iv (base64): ~24 chars for 16-byte IV - // - ciphertext (base64): varies, minimum ~16 chars for small values - // - encryptedKey (base64): ~44 chars for 32-byte data key - // Total minimum: ~96 chars, so 50 is a safe lower bound - // This prevents false positives on non-encrypted strings that happen to have 4 colons - if (value.length < 50) { - return false; - } - - return true; + return isEncryptedEnvelope(value); } } diff --git a/packages/core/database/encryption/__tests__/prisma-extension-legacy-values.test.js b/packages/core/database/encryption/__tests__/prisma-extension-legacy-values.test.js new file mode 100644 index 000000000..d13658c3f --- /dev/null +++ b/packages/core/database/encryption/__tests__/prisma-extension-legacy-values.test.js @@ -0,0 +1,102 @@ +/** + * Values in fields that become encrypted (e.g. a module's credentialFields + * registered for the first time) used to be stored in plaintext. Through the + * Prisma extension, with a real AES Cryptor: + * - plaintext that merely contains colons is not mistaken for ciphertext; + * - values round-trip with their type (a numeric string stays a string); + * - a real ciphertext that can't be decrypted still throws. + */ +const { createEncryptionExtension } = require('../prisma-encryption-extension'); +const { + registerCustomSchema, + resetCustomSchema, +} = require('../encryption-schema-registry'); +const { Cryptor } = require('../../../encrypt/Cryptor'); + +describe('Prisma encryption extension with legacy and typed values', () => { + const originalEnv = { ...process.env }; + let extension; + let stored; + + // Stand-in for the database: create stores args.data, find returns it. + const create = (data) => + extension.query.$allModels.create({ + model: 'Credential', + args: { data }, + query: async (args) => { + stored = args.data; + return args.data; + }, + }); + const read = (row) => + extension.query.$allModels.findUnique({ + model: 'Credential', + args: { where: { id: 'c1' } }, + query: async () => row, + }); + + beforeEach(() => { + process.env.AES_KEY_ID = 'legacy-values-key'; + process.env.AES_KEY = '12345678901234567890123456789012'; + registerCustomSchema({ + Credential: { fields: ['data.webhook_secret', 'data.pin'] }, + }); + extension = createEncryptionExtension({ + cryptor: new Cryptor({ shouldUseAws: false }), + enabled: true, + }); + }); + + afterEach(() => { + resetCustomSchema(); + process.env = { ...originalEnv }; + }); + + it('reads legacy plaintext containing colons as plaintext', async () => { + const legacy = { + id: 'c1', + data: { + webhook_secret: 'a:b:c:d', + pin: 'https://user:pass@example.com:8443/x', + }, + }; + + const result = await read(legacy); + + expect(result.data.webhook_secret).toBe('a:b:c:d'); + expect(result.data.pin).toBe('https://user:pass@example.com:8443/x'); + }); + + it('encrypts plaintext containing colons on write', async () => { + await create({ data: { webhook_secret: 'a:b:c:d' } }); + + expect(stored.data.webhook_secret).not.toBe('a:b:c:d'); + const result = await read(stored); + expect(result.data.webhook_secret).toBe('a:b:c:d'); + }); + + it('round-trips a numeric string as a string', async () => { + await create({ data: { pin: '123456', webhook_secret: 'true' } }); + + const result = await read(stored); + + expect(result.data.pin).toBe('123456'); + expect(result.data.webhook_secret).toBe('true'); + }); + + it('round-trips non-string values with their type', async () => { + await create({ data: { pin: 1234, webhook_secret: { k: 'v' } } }); + + const result = await read(stored); + + expect(result.data.pin).toBe(1234); + expect(result.data.webhook_secret).toEqual({ k: 'v' }); + }); + + it('still throws when a real ciphertext cannot be decrypted', async () => { + await create({ data: { webhook_secret: 'secret' } }); + process.env.AES_KEY_ID = 'rotated-away'; + + await expect(read(stored)).rejects.toThrow('Encryption key not found'); + }); +}); diff --git a/packages/core/database/encryption/ciphertext-format.js b/packages/core/database/encryption/ciphertext-format.js new file mode 100644 index 000000000..0ac2e95b0 --- /dev/null +++ b/packages/core/database/encryption/ciphertext-format.js @@ -0,0 +1,24 @@ +/** + * Recognises values produced by Cryptor.encrypt(): + * keyId:iv:ciphertext:encryptedKey + * keyId and encryptedKey are base64, iv and ciphertext are hex, so all four + * parts match the base64 alphabet. Real envelopes are well over 50 + * characters (the encrypted data key alone is longer). + * + * Shared by the Prisma (FieldEncryptionService) and DocumentDB paths so a + * plaintext value that happens to contain colons (`a:b:c:d`, a URL with + * userinfo and a port) is never mistaken for ciphertext: on write it gets + * encrypted, on read it is returned as plaintext. + */ +const BASE64_PART = /^[A-Za-z0-9+/=]+$/; +const MIN_ENVELOPE_LENGTH = 50; + +function isEncryptedEnvelope(value) { + if (typeof value !== 'string' || value.length < MIN_ENVELOPE_LENGTH) { + return false; + } + const parts = value.split(':'); + return parts.length === 4 && parts.every((part) => BASE64_PART.test(part)); +} + +module.exports = { isEncryptedEnvelope }; diff --git a/packages/core/database/encryption/field-encryption-service.js b/packages/core/database/encryption/field-encryption-service.js index 86f677e01..87e4f389b 100644 --- a/packages/core/database/encryption/field-encryption-service.js +++ b/packages/core/database/encryption/field-encryption-service.js @@ -1,3 +1,5 @@ +const { isEncryptedEnvelope } = require('./ciphertext-format'); + /** * Field Encryption Service * @@ -147,12 +149,7 @@ class FieldEncryptionService { } _isEncrypted(value) { - if (typeof value !== 'string') { - return false; - } - - const parts = value.split(':'); - return parts.length >= 4; + return isEncryptedEnvelope(value); } _getNestedValue(obj, path) { @@ -218,22 +215,22 @@ class FieldEncryptionService { } /** - * Serialize a value for encryption - * Objects/arrays are JSON stringified, primitives are converted to strings + * Serialize a value for encryption. + * Every value is JSON-encoded, strings included, so the type survives + * the round trip: '123456' is stored as "\"123456\"" and reads back as a + * string, 1234 reads back as a number. * @private */ _serializeForEncryption(value) { - if (typeof value === 'object' && value !== null) { - // JSON.stringify for objects and arrays - return JSON.stringify(value); - } - // For primitives (string, number, boolean), convert to string - return String(value); + return JSON.stringify(value); } /** - * Deserialize a value after decryption - * Attempts to parse as JSON, returns string if parsing fails + * Deserialize a value after decryption. + * Parses the JSON written by _serializeForEncryption. Values encrypted by + * earlier versions stored strings raw: those that aren't valid JSON come + * back unchanged; a raw legacy string that is also valid JSON (e.g. + * '123456') still parses as it always did. * @private */ _deserializeAfterDecryption(value) { diff --git a/packages/core/database/encryption/field-encryption-service.test.js b/packages/core/database/encryption/field-encryption-service.test.js index 6d7a7e30a..490500e96 100644 --- a/packages/core/database/encryption/field-encryption-service.test.js +++ b/packages/core/database/encryption/field-encryption-service.test.js @@ -1,41 +1,33 @@ const { FieldEncryptionService } = require('./field-encryption-service'); +const MOCK_KEY_ID = Buffer.from('mock-key-id').toString('base64'); +const MOCK_IV = 'aabbccddeeff00112233445566778899'; +const MOCK_ENC_KEY = Buffer.from('mock-encrypted-data-key').toString('base64'); +const enc = (plaintext) => + `${MOCK_KEY_ID}:${MOCK_IV}:${Buffer.from(plaintext).toString( + 'base64' + )}:${MOCK_ENC_KEY}`; +// Values are JSON-encoded before encryption, strings included. +const encValue = (value) => enc(JSON.stringify(value)); + describe('FieldEncryptionService', () => { let mockCryptor; let mockSchema; let service; beforeEach(() => { - // Mock Cryptor + // Mock Cryptor emitting the real envelope shape + // (keyId:iv:ciphertext:encryptedKey) with the plaintext as base64 + // ciphertext; only such envelopes are treated as encrypted. mockCryptor = { - encrypt: jest - .fn() - .mockImplementation( - (value) => `encrypted:${value}:keydata:enckey` - ), - decrypt: jest - .fn() - .mockImplementation((value) => { - // Handle multiple encrypted formats - // Format 1: "encrypted:ORIGINAL:keydata:enckey" - // Format 2: "keyId:ORIGINAL:iv:enckey" - - // Try format 1 (from our new tests) - const prefix1 = 'encrypted:'; - const suffix1 = ':keydata:enckey'; - if (value.startsWith(prefix1) && value.endsWith(suffix1)) { - return value.slice(prefix1.length, -suffix1.length); - } - - // Try format 2 (from existing tests) - const prefix2 = 'keyId:'; - const suffix2 = ':iv:enckey'; - if (value.startsWith(prefix2) && value.endsWith(suffix2)) { - return value.slice(prefix2.length, -suffix2.length); - } - - return value; // Fallback for non-standard format - }), + encrypt: jest.fn().mockImplementation((value) => enc(value)), + decrypt: jest.fn().mockImplementation((value) => { + const parts = value.split(':'); + if (parts.length !== 4 || parts[0] !== MOCK_KEY_ID) { + throw new Error('Invalid encrypted format'); + } + return Buffer.from(parts[2], 'base64').toString(); + }), }; // Mock Schema Registry @@ -99,13 +91,13 @@ describe('FieldEncryptionService', () => { const result = await service.encryptFields('Credential', document); - expect(mockCryptor.encrypt).toHaveBeenCalledWith('secret123'); - expect(mockCryptor.encrypt).toHaveBeenCalledWith('refresh456'); + expect(mockCryptor.encrypt).toHaveBeenCalledWith(JSON.stringify('secret123')); + expect(mockCryptor.encrypt).toHaveBeenCalledWith(JSON.stringify('refresh456')); expect(result.data.access_token).toBe( - 'encrypted:secret123:keydata:enckey' + encValue('secret123') ); expect(result.data.refresh_token).toBe( - 'encrypted:refresh456:keydata:enckey' + encValue('refresh456') ); expect(result.data.other).toBe('public'); // Not encrypted }); @@ -118,9 +110,9 @@ describe('FieldEncryptionService', () => { const result = await service.encryptFields('User', document); - expect(mockCryptor.encrypt).toHaveBeenCalledWith('password_hash'); + expect(mockCryptor.encrypt).toHaveBeenCalledWith(JSON.stringify('password_hash')); expect(result.hashword).toBe( - 'encrypted:password_hash:keydata:enckey' + encValue('password_hash') ); expect(result.id).toBe('123'); // Not encrypted }); @@ -145,7 +137,7 @@ describe('FieldEncryptionService', () => { await service.encryptFields('Credential', document); expect(mockCryptor.encrypt).toHaveBeenCalledTimes(1); - expect(mockCryptor.encrypt).toHaveBeenCalledWith('valid'); + expect(mockCryptor.encrypt).toHaveBeenCalledWith(JSON.stringify('valid')); }); it('should skip undefined values', async () => { @@ -159,7 +151,7 @@ describe('FieldEncryptionService', () => { await service.encryptFields('Credential', document); expect(mockCryptor.encrypt).toHaveBeenCalledTimes(1); - expect(mockCryptor.encrypt).toHaveBeenCalledWith('valid'); + expect(mockCryptor.encrypt).toHaveBeenCalledWith(JSON.stringify('valid')); }); it('should skip empty strings', async () => { @@ -173,13 +165,13 @@ describe('FieldEncryptionService', () => { await service.encryptFields('Credential', document); expect(mockCryptor.encrypt).toHaveBeenCalledTimes(1); - expect(mockCryptor.encrypt).toHaveBeenCalledWith('valid'); + expect(mockCryptor.encrypt).toHaveBeenCalledWith(JSON.stringify('valid')); }); it('should skip already encrypted values', async () => { const document = { data: { - access_token: 'already:encrypted:data:key', + access_token: enc('already'), refresh_token: 'plain', }, }; @@ -187,7 +179,7 @@ describe('FieldEncryptionService', () => { await service.encryptFields('Credential', document); expect(mockCryptor.encrypt).toHaveBeenCalledTimes(1); - expect(mockCryptor.encrypt).toHaveBeenCalledWith('plain'); + expect(mockCryptor.encrypt).toHaveBeenCalledWith(JSON.stringify('plain')); }); it('should not mutate original document', async () => { @@ -231,7 +223,7 @@ describe('FieldEncryptionService', () => { // The encrypted value should be the JSON string encrypted expect(encrypted.mapping).toBe( - `encrypted:${JSON.stringify(mappingObject)}:keydata:enckey` + encValue(mappingObject) ); // Now decrypt and verify object is restored @@ -277,8 +269,8 @@ describe('FieldEncryptionService', () => { const document = { id: '123', data: { - access_token: 'keyId:secret123:iv:enckey', - refresh_token: 'keyId:refresh456:iv:enckey', + access_token: enc('secret123'), + refresh_token: enc('refresh456'), other: 'public', }, }; @@ -286,10 +278,10 @@ describe('FieldEncryptionService', () => { const result = await service.decryptFields('Credential', document); expect(mockCryptor.decrypt).toHaveBeenCalledWith( - 'keyId:secret123:iv:enckey' + enc('secret123') ); expect(mockCryptor.decrypt).toHaveBeenCalledWith( - 'keyId:refresh456:iv:enckey' + enc('refresh456') ); expect(result.data.access_token).toBe('secret123'); expect(result.data.refresh_token).toBe('refresh456'); @@ -299,13 +291,13 @@ describe('FieldEncryptionService', () => { it('should decrypt top-level fields', async () => { const document = { id: '123', - hashword: 'keyId:password_hash:iv:enckey', + hashword: enc('password_hash'), }; const result = await service.decryptFields('User', document); expect(mockCryptor.decrypt).toHaveBeenCalledWith( - 'keyId:password_hash:iv:enckey' + enc('password_hash') ); expect(result.hashword).toBe('password_hash'); }); @@ -314,7 +306,7 @@ describe('FieldEncryptionService', () => { const document = { data: { access_token: 'plaintext', // Not encrypted format - refresh_token: 'keyId:encrypted:iv:enckey', + refresh_token: enc('encrypted'), }, }; @@ -322,13 +314,13 @@ describe('FieldEncryptionService', () => { expect(mockCryptor.decrypt).toHaveBeenCalledTimes(1); expect(mockCryptor.decrypt).toHaveBeenCalledWith( - 'keyId:encrypted:iv:enckey' + enc('encrypted') ); }); it('should not mutate original document', async () => { const document = { - data: { access_token: 'keyId:secret:iv:enckey' }, + data: { access_token: enc('secret') }, }; const original = JSON.parse(JSON.stringify(document)); @@ -344,8 +336,8 @@ describe('FieldEncryptionService', () => { const document = { data: { - access_token: 'keyId:secret:iv:enckey', - refresh_token: 'keyId:valid:iv:enckey', + access_token: enc('secret'), + refresh_token: enc('valid'), }, }; @@ -369,10 +361,10 @@ describe('FieldEncryptionService', () => { expect(result).toHaveLength(2); expect(result[0].data.access_token).toBe( - 'encrypted:secret1:keydata:enckey' + encValue('secret1') ); expect(result[1].data.access_token).toBe( - 'encrypted:secret2:keydata:enckey' + encValue('secret2') ); }); @@ -394,8 +386,8 @@ describe('FieldEncryptionService', () => { describe('decryptFieldsInBulk', () => { it('should decrypt multiple documents', async () => { const documents = [ - { data: { access_token: 'keyId:secret1:iv:enckey' } }, - { data: { access_token: 'keyId:secret2:iv:enckey' } }, + { data: { access_token: enc('secret1') } }, + { data: { access_token: enc('secret2') } }, ]; const result = await service.decryptFieldsInBulk( @@ -410,17 +402,22 @@ describe('FieldEncryptionService', () => { }); describe('_isEncrypted', () => { - it('should detect encrypted format', () => { - expect(service._isEncrypted('keyId:data:iv:enckey')).toBe(true); - expect( - service._isEncrypted('keyId:longer:data:with:colons:enckey') - ).toBe(true); + it('should detect the Cryptor envelope format', () => { + expect(service._isEncrypted(enc('data'))).toBe(true); }); it('should reject non-encrypted formats', () => { expect(service._isEncrypted('plaintext')).toBe(false); expect(service._isEncrypted('one:two:three')).toBe(false); expect(service._isEncrypted('one:two')).toBe(false); + // Plaintext with 3+ colons is not ciphertext + expect(service._isEncrypted('a:b:c:d')).toBe(false); + expect( + service._isEncrypted('https://user:pass@example.com:8443/x') + ).toBe(false); + expect( + service._isEncrypted('keyId:longer:data:with:colons:enckey') + ).toBe(false); expect(service._isEncrypted(null)).toBe(false); expect(service._isEncrypted(undefined)).toBe(false); expect(service._isEncrypted(123)).toBe(false); @@ -575,7 +572,7 @@ describe('FieldEncryptionService', () => { }); it('should still decrypt opted-out fields on read (legacy data)', async () => { - const encryptedBlob = 'encrypted:{"crmId":"abc"}:keydata:enckey'; + const encryptedBlob = enc('{"crmId":"abc"}'); const document = { id: '123', mapping: encryptedBlob, diff --git a/packages/core/database/encryption/prisma-encryption-extension.test.js b/packages/core/database/encryption/prisma-encryption-extension.test.js index f67e0a85d..bcfcffea1 100644 --- a/packages/core/database/encryption/prisma-encryption-extension.test.js +++ b/packages/core/database/encryption/prisma-encryption-extension.test.js @@ -1,23 +1,28 @@ const { createEncryptionExtension } = require('./prisma-encryption-extension'); +const MOCK_KEY_ID = Buffer.from('mock-key-id').toString('base64'); +const MOCK_IV = 'aabbccddeeff00112233445566778899'; +const MOCK_ENC_KEY = Buffer.from('mock-encrypted-data-key').toString('base64'); +const enc = (plaintext) => + `${MOCK_KEY_ID}:${MOCK_IV}:${Buffer.from(plaintext).toString( + 'base64' + )}:${MOCK_ENC_KEY}`; +// Values are JSON-encoded before encryption, strings included. +const encValue = (value) => enc(JSON.stringify(value)); + describe('Prisma Encryption Extension', () => { let mockCryptor; let mockQuery; beforeEach(() => { - // Mock Cryptor + // Mock Cryptor emitting the real envelope shape + // (keyId:iv:ciphertext:encryptedKey), plaintext as base64 ciphertext. mockCryptor = { - encrypt: jest - .fn() - .mockImplementation( - (value) => `encrypted:${value}:iv:enckey` - ), - decrypt: jest - .fn() - .mockImplementation((value) => { - const parts = value.split(':'); - return parts[1]; // Extract original value - }), + encrypt: jest.fn().mockImplementation((value) => enc(value)), + decrypt: jest.fn().mockImplementation((value) => { + const parts = value.split(':'); + return Buffer.from(parts[2], 'base64').toString(); + }), }; // Mock Prisma query function @@ -83,7 +88,7 @@ describe('Prisma Encryption Extension', () => { }, mockResult: { id: '1', - data: { access_token: 'encrypted:secret123:iv:enckey' }, + data: { access_token: encValue('secret123') }, }, }; @@ -94,9 +99,9 @@ describe('Prisma Encryption Extension', () => { query: mockQuery, }); - expect(mockCryptor.encrypt).toHaveBeenCalledWith('secret123'); + expect(mockCryptor.encrypt).toHaveBeenCalledWith(JSON.stringify('secret123')); expect(args.data.data.access_token).toBe( - 'encrypted:secret123:iv:enckey' + encValue('secret123') ); }); @@ -105,7 +110,7 @@ describe('Prisma Encryption Extension', () => { data: { data: { access_token: 'secret123' } }, mockResult: { id: '1', - data: { access_token: 'encrypted:secret123:iv:enckey' }, + data: { access_token: encValue('secret123') }, }, }; @@ -117,7 +122,7 @@ describe('Prisma Encryption Extension', () => { }); expect(mockCryptor.decrypt).toHaveBeenCalledWith( - 'encrypted:secret123:iv:enckey' + encValue('secret123') ); expect(result.data.access_token).toBe('secret123'); }); @@ -156,13 +161,13 @@ describe('Prisma Encryption Extension', () => { query: mockQuery, }); - expect(mockCryptor.encrypt).toHaveBeenCalledWith('secret1'); - expect(mockCryptor.encrypt).toHaveBeenCalledWith('secret2'); + expect(mockCryptor.encrypt).toHaveBeenCalledWith(JSON.stringify('secret1')); + expect(mockCryptor.encrypt).toHaveBeenCalledWith(JSON.stringify('secret2')); expect(args.data[0].data.access_token).toBe( - 'encrypted:secret1:iv:enckey' + encValue('secret1') ); expect(args.data[1].data.access_token).toBe( - 'encrypted:secret2:iv:enckey' + encValue('secret2') ); }); @@ -179,7 +184,7 @@ describe('Prisma Encryption Extension', () => { query: mockQuery, }); - expect(mockCryptor.encrypt).toHaveBeenCalledWith('secret'); + expect(mockCryptor.encrypt).toHaveBeenCalledWith(JSON.stringify('secret')); }); }); @@ -189,7 +194,7 @@ describe('Prisma Encryption Extension', () => { data: { data: { access_token: 'newsecret' } }, mockResult: { id: '1', - data: { access_token: 'encrypted:newsecret:iv:enckey' }, + data: { access_token: encValue('newsecret') }, }, }; @@ -200,7 +205,7 @@ describe('Prisma Encryption Extension', () => { query: mockQuery, }); - expect(mockCryptor.encrypt).toHaveBeenCalledWith('newsecret'); + expect(mockCryptor.encrypt).toHaveBeenCalledWith(JSON.stringify('newsecret')); }); it('should decrypt result after update', async () => { @@ -208,7 +213,7 @@ describe('Prisma Encryption Extension', () => { data: { data: { access_token: 'newsecret' } }, mockResult: { id: '1', - data: { access_token: 'encrypted:newsecret:iv:enckey' }, + data: { access_token: encValue('newsecret') }, }, }; @@ -230,7 +235,7 @@ describe('Prisma Encryption Extension', () => { update: { data: { access_token: 'updatesecret' } }, mockResult: { id: '1', - data: { access_token: 'encrypted:createsecret:iv:enckey' }, + data: { access_token: encValue('createsecret') }, }, }; @@ -241,8 +246,8 @@ describe('Prisma Encryption Extension', () => { query: mockQuery, }); - expect(mockCryptor.encrypt).toHaveBeenCalledWith('createsecret'); - expect(mockCryptor.encrypt).toHaveBeenCalledWith('updatesecret'); + expect(mockCryptor.encrypt).toHaveBeenCalledWith(JSON.stringify('createsecret')); + expect(mockCryptor.encrypt).toHaveBeenCalledWith(JSON.stringify('updatesecret')); }); }); @@ -252,7 +257,7 @@ describe('Prisma Encryption Extension', () => { where: { id: '1' }, mockResult: { id: '1', - data: { access_token: 'encrypted:secret:iv:enckey' }, + data: { access_token: encValue('secret') }, }, }; @@ -264,7 +269,7 @@ describe('Prisma Encryption Extension', () => { }); expect(mockCryptor.decrypt).toHaveBeenCalledWith( - 'encrypted:secret:iv:enckey' + encValue('secret') ); expect(result.data.access_token).toBe('secret'); }); @@ -293,11 +298,11 @@ describe('Prisma Encryption Extension', () => { mockResult: [ { id: '1', - data: { access_token: 'encrypted:secret1:iv:enckey' }, + data: { access_token: encValue('secret1') }, }, { id: '2', - data: { access_token: 'encrypted:secret2:iv:enckey' }, + data: { access_token: encValue('secret2') }, }, ], }; @@ -336,7 +341,7 @@ describe('Prisma Encryption Extension', () => { where: { id: '1' }, mockResult: { id: '1', - data: { access_token: 'encrypted:secret:iv:enckey' }, + data: { access_token: encValue('secret') }, }, }; @@ -411,8 +416,8 @@ describe('Prisma Encryption Extension', () => { mockResult: { id: '123', data: { - access_token: 'encrypted:secret:iv:enckey', - refresh_token: 'encrypted:refresh:iv:enckey', + access_token: encValue('secret'), + refresh_token: encValue('refresh'), other: 'public', }, }, @@ -426,8 +431,8 @@ describe('Prisma Encryption Extension', () => { }); // Verify encryption was called for encrypted fields - expect(mockCryptor.encrypt).toHaveBeenCalledWith('secret'); - expect(mockCryptor.encrypt).toHaveBeenCalledWith('refresh'); + expect(mockCryptor.encrypt).toHaveBeenCalledWith(JSON.stringify('secret')); + expect(mockCryptor.encrypt).toHaveBeenCalledWith(JSON.stringify('refresh')); // 'other' should not be encrypted // Verify decryption in result From be66998dd4a946bb016ff5f72e28588063ac3a97 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Sat, 24 Oct 2026 00:23:49 -0400 Subject: [PATCH 38/44] ci: only cancel superseded runs on pull requests Review of #710: with cancel-in-progress on every ref, two quick merges to next cancelled the first commit's run, and release.yml publishes a prerelease for every push to next, so that version never got a CI result. Superseded PR runs are still cancelled; pushes to main/next are not. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- .github/workflows/frigg-ci.js.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/frigg-ci.js.yml b/.github/workflows/frigg-ci.js.yml index 2a0fe6afa..9a6d16661 100644 --- a/.github/workflows/frigg-ci.js.yml +++ b/.github/workflows/frigg-ci.js.yml @@ -11,10 +11,12 @@ on: pull_request: branches: ["**"] -# A newer push to the same branch/PR supersedes the running check. +# A newer push to the same PR supersedes its running check. Pushes to +# main/next are never cancelled: release.yml publishes every push to next, +# so each published commit must keep its own CI result. concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: build: From 3c342b422845e513b8731d1c725f966d667d9638 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Sat, 24 Oct 2026 00:24:51 -0400 Subject: [PATCH 39/44] test(devtools): stop blessing the route-association rename; skip it pending #714 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #710: two VpcBuilder tests had been flipped to accept that a stack with FriggSubnet{1,2}RouteAssociation gets FriggPrivateSubnet{1,2}RouteTableAssociation instead. History doesn't settle it. 37a354e8 changed the source to always emit the new IDs, with a comment saying this is deliberate (force CloudFormation to recreate drifted associations), and in the same commit kept tests that expect the old IDs "to avoid AlreadyExists errors". A subnet takes only one explicit route-table association and CloudFormation creates the new logical resource before deleting the old one, so on a stack whose old associations are intact the rename could fail with Resource.AlreadyAssociated and roll back. Choosing between that and the drift fix is a product call, so the source is left alone and: - "should add UpdateReplacePolicy…" is back to its original old-ID assertions and it.skip'ped, pointing at #714; - the backwards-compatibility test keeps running for the NAT route and VPC endpoint IDs but no longer asserts the association IDs either way. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- .../domains/networking/vpc-builder.test.js | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/packages/devtools/infrastructure/domains/networking/vpc-builder.test.js b/packages/devtools/infrastructure/domains/networking/vpc-builder.test.js index aa3ed14dd..a39d52f31 100644 --- a/packages/devtools/infrastructure/domains/networking/vpc-builder.test.js +++ b/packages/devtools/infrastructure/domains/networking/vpc-builder.test.js @@ -810,7 +810,12 @@ describe('VpcBuilder', () => { expect(result.resources.FriggPrivateSubnet2RouteTableAssociation.Properties.RouteTableId).toEqual({ Ref: 'FriggLambdaRouteTable' }); }); - it('should add UpdateReplacePolicy to force association recreation on updates', async () => { + // SKIPPED: whether existing stacks keep FriggSubnetNRouteAssociation or move + // to FriggPrivateSubnetNRouteTableAssociation is unresolved. Since 37a354e8 + // the source always uses the new IDs; this test (same commit) expects the old + // ones. Moving could hit Resource.AlreadyAssociated on upgrade. + // Tracked in https://github.com/friggframework/frigg/issues/714 + it.skip('should add UpdateReplacePolicy to force association recreation on updates', async () => { const appDefinition = { vpc: { enable: true, @@ -833,15 +838,9 @@ describe('VpcBuilder', () => { const result = await vpcBuilder.build(appDefinition, discoveredResources); - // The builder always emits the new association logical IDs, even when - // the stack still has the old FriggSubnetNRouteAssociation ones, so - // CloudFormation replaces drifted associations (see createNatGatewayRouting). - expect(result.resources.FriggSubnet1RouteAssociation).toBeUndefined(); - expect(result.resources.FriggSubnet2RouteAssociation).toBeUndefined(); - // Verify associations have UpdateReplacePolicy to force recreation - expect(result.resources.FriggPrivateSubnet1RouteTableAssociation.UpdateReplacePolicy).toBe('Delete'); - expect(result.resources.FriggPrivateSubnet2RouteTableAssociation.UpdateReplacePolicy).toBe('Delete'); + expect(result.resources.FriggSubnet1RouteAssociation.UpdateReplacePolicy).toBe('Delete'); + expect(result.resources.FriggSubnet2RouteAssociation.UpdateReplacePolicy).toBe('Delete'); // This forces CloudFormation to delete old associations and create new ones // instead of trying to update them in-place (which doesn't work) @@ -1603,13 +1602,9 @@ describe('VpcBuilder', () => { expect(result.resources.FriggNATRoute.Type).toBe('AWS::EC2::Route'); expect(result.resources.FriggPrivateRoute).toBeUndefined(); // Should NOT create new ID - // Subnet associations are the exception: the builder deliberately moves - // them to the new logical IDs (with UpdateReplacePolicy: Delete) so - // drifted associations are recreated rather than updated in place. - expect(result.resources.FriggSubnet1RouteAssociation).toBeUndefined(); - expect(result.resources.FriggSubnet2RouteAssociation).toBeUndefined(); - expect(result.resources.FriggPrivateSubnet1RouteTableAssociation).toBeDefined(); - expect(result.resources.FriggPrivateSubnet2RouteTableAssociation).toBeDefined(); + // Subnet route-table association IDs are deliberately not asserted + // here: which IDs an existing stack should keep is unresolved (#714, + // see the skipped UpdateReplacePolicy test above). expect(result.resources.VPCEndpointS3).toBeDefined(); expect(result.resources.VPCEndpointS3.Type).toBe('AWS::EC2::VPCEndpoint'); From 31269616f9fc31e3f85a21a5545bae06e42c660c Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Sat, 24 Oct 2026 00:27:13 -0400 Subject: [PATCH 40/44] fix(test): depend on mongodb-memory-server-core so nothing downloads MongoDB at install Review of #710: mongodb-memory-server's postinstall downloads the library's default binary (8.2.6) on every `npm ci`, while TestMongo pins and downloads 8.0.17 at first use, so every install fetched a ~100 MB binary that nothing used. Because @friggframework/devtools depends on @friggframework/test at runtime, that happened in every Frigg app too, and MONGOMS_DISABLE_POSTINSTALL or package.json config in this repo can't reach an app's install (the hook reads INIT_CWD's package.json). @friggframework/test now depends on mongodb-memory-server-core, the same library without the install hook; TestMongo only ever fetches the pinned version. The lockfile change is just the dropped wrapper package (and its tslib) plus the renamed dependency. install-footprint.test.js fails on the previous dependency. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- package-lock.json | 22 +-------------------- packages/test/install-footprint.test.js | 26 +++++++++++++++++++++++++ packages/test/mongodb.js | 2 +- packages/test/package.json | 2 +- 4 files changed, 29 insertions(+), 23 deletions(-) create mode 100644 packages/test/install-footprint.test.js diff --git a/package-lock.json b/package-lock.json index aca5bb489..355315366 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25370,20 +25370,6 @@ "node": ">=18" } }, - "node_modules/mongodb-memory-server": { - "version": "11.3.0", - "resolved": "https://registry.npmjs.org/mongodb-memory-server/-/mongodb-memory-server-11.3.0.tgz", - "integrity": "sha512-8ZCDrraqKqmAikoeKuEq+16jeWdVKwjtEAuMxxeQctHiGSGYt17W9LmoRzV1Nwnz7owyxwDKMhow4IVkNYYRKw==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "mongodb-memory-server-core": "11.3.0", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=20.19.0" - } - }, "node_modules/mongodb-memory-server-core": { "version": "11.3.0", "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-11.3.0.tgz", @@ -25485,12 +25471,6 @@ "node": ">=12" } }, - "node_modules/mongodb-memory-server/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, "node_modules/mongodb/node_modules/bson": { "version": "7.3.3", "resolved": "https://registry.npmjs.org/bson/-/bson-7.3.3.tgz", @@ -35268,7 +35248,7 @@ "eslint-plugin-no-only-tests": "^3.0.0", "eslint-plugin-yaml": "^0.5.0", "jest-runner-groups": "^2.2.0", - "mongodb-memory-server": "^11.3.0", + "mongodb-memory-server-core": "^11.3.0", "open": "^8.4.2" }, "devDependencies": { diff --git a/packages/test/install-footprint.test.js b/packages/test/install-footprint.test.js new file mode 100644 index 000000000..4ae4043e5 --- /dev/null +++ b/packages/test/install-footprint.test.js @@ -0,0 +1,26 @@ +// @friggframework/test is a runtime dependency of @friggframework/devtools, +// so it is installed in every Frigg app. TestMongo downloads its pinned +// MongoDB binary on first use; no MongoDB package it depends on may also +// download one at install time (mongodb-memory-server's postinstall did, +// and fetched the library default rather than the pinned version). +describe('@friggframework/test install footprint', () => { + const { dependencies } = require('./package.json'); + const mongoDependencies = Object.keys(dependencies).filter((name) => + name.includes('mongo') + ); + + it('uses a MongoDB in-memory server package', () => { + expect(mongoDependencies).not.toEqual([]); + }); + + it.each(mongoDependencies)('%s has no install-time scripts', (name) => { + const manifest = require(require.resolve(`${name}/package.json`, { + paths: [__dirname], + })); + const scripts = manifest.scripts || {}; + + expect( + ['preinstall', 'install', 'postinstall'].filter((hook) => scripts[hook]) + ).toEqual([]); + }); +}); diff --git a/packages/test/mongodb.js b/packages/test/mongodb.js index e5492618a..974335a93 100644 --- a/packages/test/mongodb.js +++ b/packages/test/mongodb.js @@ -1,4 +1,4 @@ -const { MongoMemoryServer } = require('mongodb-memory-server'); +const { MongoMemoryServer } = require('mongodb-memory-server-core'); // Pin the MongoDB binary instead of relying on the library default. The old // default (5.0.19) only ships Ubuntu 20.04 builds that link against OpenSSL diff --git a/packages/test/package.json b/packages/test/package.json index 23c03d7d1..af7c7af56 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -11,7 +11,7 @@ "eslint-plugin-no-only-tests": "^3.0.0", "eslint-plugin-yaml": "^0.5.0", "jest-runner-groups": "^2.2.0", - "mongodb-memory-server": "^11.3.0", + "mongodb-memory-server-core": "^11.3.0", "open": "^8.4.2" }, "devDependencies": { From 40c6a2a4e825d69c99f036d68dc312a92d65ee46 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Sat, 24 Oct 2026 00:27:50 -0400 Subject: [PATCH 41/44] ci: make the CLI smoke test catch a dropped or renamed command Review of #710: `frigg --help` exits 0 (commander prints the top-level help), so the per-command --help loop could never fail for a missing command. `frigg --help` already loads every command module; the step now also checks that each expected command is listed in it. Checked locally: the list passes, and adding a nonexistent command fails the step. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- .github/workflows/frigg-ci.js.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/frigg-ci.js.yml b/.github/workflows/frigg-ci.js.yml index 9a6d16661..7cf24c57a 100644 --- a/.github/workflows/frigg-ci.js.yml +++ b/.github/workflows/frigg-ci.js.yml @@ -50,13 +50,19 @@ jobs: run: npm run test:all timeout-minutes: 10 - # CLI smoke test (from the retired test-cli.yml): the `frigg` bin - # resolves and every command loads far enough to print its help. + # CLI smoke test (from the retired test-cli.yml). `frigg --help` loads + # every command module (index.js requires them eagerly), and each + # expected command must be listed: `frigg --help` exits 0, so + # per-command --help calls couldn't catch a dropped or renamed command. - name: CLI smoke test 🧰 run: | - npx --no-install frigg --help + help="$(npx --no-install frigg --help)" + echo "$help" for cmd in init install start build deploy generate-iam ui db:setup doctor repair ssm auth; do - npx --no-install frigg "$cmd" --help > /dev/null + if ! grep -qE "^ ${cmd}( |\$)" <<<"$help"; then + echo "::error::frigg --help no longer lists the '${cmd}' command" + exit 1 + fi done db: From bf11c4c873bde6ca05427530dd17d6d8f06d5778 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Sat, 24 Oct 2026 00:27:50 -0400 Subject: [PATCH 42/44] ci: fail the Mongo step if the replica set never gets a primary Review of #710: the wait loop ended with `sleep 1`, so a node that never became primary left the step green and the failure surfaced later as a confusing `prisma db push` error. The step now fails with an error and the container log. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- .github/workflows/frigg-ci.js.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/frigg-ci.js.yml b/.github/workflows/frigg-ci.js.yml index 7cf24c57a..ead4e7be7 100644 --- a/.github/workflows/frigg-ci.js.yml +++ b/.github/workflows/frigg-ci.js.yml @@ -112,10 +112,19 @@ jobs: sleep 1 done docker exec mongo mongosh --quiet --eval 'rs.initiate({ _id: "rs0", members: [{ _id: 0, host: "localhost:27017" }] })' + primary=false for i in $(seq 1 30); do - [ "$(docker exec mongo mongosh --quiet --eval 'db.hello().isWritablePrimary')" = "true" ] && break + if [ "$(docker exec mongo mongosh --quiet --eval 'db.hello().isWritablePrimary')" = "true" ]; then + primary=true + break + fi sleep 1 done + if [ "$primary" != true ]; then + echo "::error::MongoDB replica set did not elect a primary within 30s" + docker logs mongo | tail -50 + exit 1 + fi - name: Use Node.js 22.x 🛠 uses: actions/setup-node@v4 From ced54b04f4c03f23ff08c911502e64533f0af1fe Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Sat, 24 Oct 2026 00:27:50 -0400 Subject: [PATCH 43/44] ci: read-only GITHUB_TOKEN for the whole Frigg CI workflow Review of #710: the build job had no permissions block. With the lint auto-commit gone no job needs write access, so contents: read is set at workflow level (db and lint already declared it). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- .github/workflows/frigg-ci.js.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/frigg-ci.js.yml b/.github/workflows/frigg-ci.js.yml index ead4e7be7..1076484db 100644 --- a/.github/workflows/frigg-ci.js.yml +++ b/.github/workflows/frigg-ci.js.yml @@ -18,6 +18,10 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +# Read-only token for every job: CI no longer pushes commits. +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest From 7cfcf1267a412053163b99b7f9a123f7e4e1bde3 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Sat, 24 Oct 2026 00:28:17 -0400 Subject: [PATCH 44/44] docs: describe *.db.test.js / test:db and opt-in TestMongo in TESTING.md Review of #710: the MongoDB section still said integration tests get an in-memory MongoDB with MONGO_URI from the test setup, which core no longer provides. It now explains the *.db.test.js convention, npm run test:db, the FRIGG_TEST_* URLs, how CI and a local run provide the databases, and TestMongo as an opt-in helper for apps. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013b2UUFaQ5Lk6TFPqzRr1j7 --- docs/TESTING.md | 71 ++++++++++++++++++++++++++++--------------------- 1 file changed, 40 insertions(+), 31 deletions(-) diff --git a/docs/TESTING.md b/docs/TESTING.md index dd3950a1b..b7150e2c8 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -239,42 +239,51 @@ npm run test:coverage ## Test Infrastructure -### MongoDB (Integration Tests) +### Databases (DB Integration Tests) + +Unit tests never get a database: core's jest `globalSetup` only sets +`STAGE=dev`, and repository unit tests mock the Prisma client. Tests that +need a real database are DB integration tests: + +- **Name them `*.db.test.js`.** The default `npm test` / `npm run test:all` + ignores them; `npm run test:db` runs them (in `packages/core`, see + `jest.db.config.js`). +- **Point them at real databases** with environment variables, defaulting to + local containers: + - `FRIGG_TEST_MONGODB_URL` — a MongoDB **replica set** (Prisma needs one for + MongoDB writes), default `mongodb://localhost:27017/frigg?replicaSet=rs0` + - `FRIGG_TEST_POSTGRES_URL` — PostgreSQL with the `prisma-postgresql` + migrations applied, default + `postgresql://postgres:postgres@localhost:5432/frigg?schema=public` +- **Generate the Prisma clients first:** `npm run prisma:generate` in + `packages/core`. + +CI runs them in the `db` job of `.github/workflows/frigg-ci.js.yml`, which +starts both databases. Locally: -Integration tests automatically get access to an in-memory MongoDB instance: - -```javascript -const { mongoose } = require('@friggframework/core'); - -/** - * @group integration - * @group infrastructure - */ -describe('UserRepository', () => { - beforeAll(async () => { - // MONGO_URI is provided by test setup - await mongoose.connect(process.env.MONGO_URI); - }); +```bash +docker run -d --name frigg-mongo -p 27017:27017 mongo:8.0 --replSet rs0 --bind_ip_all +docker exec frigg-mongo mongosh --quiet --eval 'rs.initiate({ _id: "rs0", members: [{ _id: 0, host: "localhost:27017" }] })' +docker run -d --name frigg-pg -p 5432:5432 -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=frigg postgres:16 - afterAll(async () => { - await mongoose.disconnect(); - }); +cd packages/core +npm run prisma:generate +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/frigg?schema=public" \ + npx prisma migrate deploy --schema prisma-postgresql/schema.prisma +npm run test:db +``` - beforeEach(async () => { - // Clean up between tests - await User.deleteMany({}); - }); +**In-memory MongoDB for apps (opt-in).** `@friggframework/test` still exports +`TestMongo`, which starts a pinned MongoDB binary with +mongodb-memory-server and sets `MONGO_URI`. It is no longer started for you; +start it in your own `globalSetup` or `beforeAll` if your tests need it: - it('should save user to database', async () => { - const user = await User.create({ - email: 'test@example.com', - name: 'Test User' - }); +```javascript +const { TestMongo } = require('@friggframework/test'); - expect(user._id).toBeDefined(); - expect(user.email).toBe('test@example.com'); - }); -}); +const testMongo = new TestMongo(); +beforeAll(() => testMongo.start()); // sets process.env.MONGO_URI +afterAll(() => testMongo.stop()); ``` ### Mocking