diff --git a/.secrets.baseline b/.secrets.baseline index fb6e6fd..a3d9921 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -90,6 +90,10 @@ { "path": "detect_secrets.filters.allowlist.is_line_allowlisted" }, + { + "path": "detect_secrets.filters.common.is_baseline_file", + "filename": ".secrets.baseline" + }, { "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", "min_level": 2 @@ -123,680 +127,3479 @@ } ], "results": { + "packages/orcabus-pipeline-test-utils/tests/test_aws_mocks.py": [ + { + "type": "Secret Keyword", + "filename": "packages/orcabus-pipeline-test-utils/tests/test_aws_mocks.py", + "hashed_secret": "c6074a378f5827b9b90f58db5f32dc5530179aef", + "is_verified": false, + "line_number": 71 + } + ], + "packages/orcabus-pipeline-test-utils/tests/test_property_execution_assertion.py": [ + { + "type": "Base64 High Entropy String", + "filename": "packages/orcabus-pipeline-test-utils/tests/test_property_execution_assertion.py", + "hashed_secret": "79a8afbe72a1d989b83ec4dbb7ad80356e1b43ff", + "is_verified": false, + "line_number": 56 + } + ], + "packages/orcabus-pipeline-test-utils/tests/test_property_smoke_validation.py": [ + { + "type": "Base64 High Entropy String", + "filename": "packages/orcabus-pipeline-test-utils/tests/test_property_smoke_validation.py", + "hashed_secret": "e7be9342d479416a77bc2979c4329e376cd2eee7", + "is_verified": false, + "line_number": 41 + }, + { + "type": "Base64 High Entropy String", + "filename": "packages/orcabus-pipeline-test-utils/tests/test_property_smoke_validation.py", + "hashed_secret": "0550a2cf6f1020f5c377532275e2116ffdd78490", + "is_verified": false, + "line_number": 67 + } + ], "pnpm-lock.yaml": [ { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "5dd18650c85948665c3f3d2c05c7d8f36cd46b69", + "hashed_secret": "c38e327b394c0cc28e5b39a9af4b1c859969ef29", "is_verified": false, - "line_number": 30 + "line_number": 82 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "a663af0a94cb6ab57fb8da6cdc54c8b8b5c3dd66", + "hashed_secret": "991f37e78e1c9c67b48d2de1d88816d5b5121c89", "is_verified": false, - "line_number": 33 + "line_number": 85 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "38d6743a8f320567e494fc203cf70e1b62c5df8c", + "hashed_secret": "d590e1488af6553dbab873cda16fff9798c7f5f3", "is_verified": false, - "line_number": 36 + "line_number": 88 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "90159a3d0b766d140d8e1382bddf0a5393d39060", + "hashed_secret": "545c1f84ac501d9f4cbe87e4455677e63474d42a", "is_verified": false, - "line_number": 43 + "line_number": 95 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "b77f8a6325211e0f41e292199f2ff0f61ab98395", + "hashed_secret": "093dc379f0665dae817d66ad51c531bd83faa293", "is_verified": false, - "line_number": 47 + "line_number": 102 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "9a1373fb8bc6343a2626079c5c977463daea3541", + "hashed_secret": "81fd1660ece7e07415abd7d49f0938d1cb3894a7", "is_verified": false, - "line_number": 51 + "line_number": 105 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "e768776e74a3cb5645d538a9bd074c78e7fb52b3", + "hashed_secret": "a1e4dbe75a0f44aec39fac9bee386901421e716e", "is_verified": false, - "line_number": 55 + "line_number": 109 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "150a1608ffd7395d510860bc11f1b69b49ff015a", + "hashed_secret": "04cd1f96a69cd58e16c5bb7e57b623cf2c5cacc4", "is_verified": false, - "line_number": 59 + "line_number": 112 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "3a3c0e6cef952fc58e9236a516048ccd707f81eb", + "hashed_secret": "6911f77a8212a8ea6712b927fc01b6a6112e2b3e", "is_verified": false, - "line_number": 63 + "line_number": 115 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "1d79ec8c59c929a4d8e3b2d75df670b4319a3279", + "hashed_secret": "34a65c8a0b3cca7b9a6b423c6036e90dee9db60d", "is_verified": false, - "line_number": 67 + "line_number": 119 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "35ca90e5fa9b87427a5ff8b3f333a7b7d45fc260", + "hashed_secret": "c4864c780ce67b9ed3d4785c4d95cd09f888b3f1", "is_verified": false, - "line_number": 70 + "line_number": 123 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "d15b82853a984c45aab87399878c98082bac85ba", + "hashed_secret": "328aa13dac9b4e89104af1771794c127c8e285f9", "is_verified": false, - "line_number": 74 + "line_number": 127 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "4e5ebe87f5763d5b3c5a63aeaeecb5601ba97c81", + "hashed_secret": "00c2db8e81c33feb99be2548b6f0e3fed93a7f93", "is_verified": false, - "line_number": 78 + "line_number": 131 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "c3d7dc90b070205de6307af836e3aabb44d710b6", + "hashed_secret": "cb8229d78a56dcb26b78766a134d7900a4ace9e2", "is_verified": false, - "line_number": 96 + "line_number": 135 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "4cd454d960136b7b4f74cca3c12010ff2ebd7663", + "hashed_secret": "a38246471e6cec2813cab31de59e074cd90bad8d", "is_verified": false, - "line_number": 101 + "line_number": 139 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "6791035095fb16620100008d8bd175f89259e13f", + "hashed_secret": "9a32c88eed3872bdb041624eae43123fb69570fb", "is_verified": false, - "line_number": 105 + "line_number": 143 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "c0d0aae3b90810110f3ff7c2a447e9358df29c4c", + "hashed_secret": "6db20d038f9f53e87ba20c8e004621c7bf683a09", "is_verified": false, - "line_number": 109 + "line_number": 147 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "5e5e944819f9ed1defbf3b9cf387214fb2f04d61", + "hashed_secret": "633c09a22476f0c2ac9e8b821cf3fe143d69aa59", "is_verified": false, - "line_number": 113 + "line_number": 151 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "6f67b0fd13f6feb5f6132a68f8c7f59b6bdcf97e", + "hashed_secret": "d1f338948623a78302eb755ca92690b7108085c9", "is_verified": false, - "line_number": 117 + "line_number": 155 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "43e69c85090264b5501c0964aaa3d048ee08fd37", + "hashed_secret": "417b7df94e279fd9e6541481de87ffa38f57e5ba", "is_verified": false, - "line_number": 120 + "line_number": 159 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "bda118bb6ac4d344bfd8dc748db3bd2039d09e06", + "hashed_secret": "42ac427cde035c880abfb97033ea7f44a0b5ffc9", "is_verified": false, - "line_number": 124 + "line_number": 163 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "d9bb8f546fcf6109d4f1b7a2f084b7839fe765ec", + "hashed_secret": "b69c31a2cffc74e8a0fa06811ace673e1d1f118e", "is_verified": false, - "line_number": 128 + "line_number": 167 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "4ed49acfbd180533265d2099f3043963c4a24443", + "hashed_secret": "8aa4ad035b100bf76dacba5c0cf133ed634ad112", "is_verified": false, - "line_number": 132 + "line_number": 171 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "428e72f71455a785963f1df5f85c26c6d6ae519b", + "hashed_secret": "39138c3b51f30df671feac81299ed2b40a9d2f96", "is_verified": false, - "line_number": 136 + "line_number": 175 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "4ebb417ccb84b97e3807fbc0690f7d5019974783", + "hashed_secret": "54cbfdc2046e3281624b4be9008d0be29c4d19c3", "is_verified": false, - "line_number": 139 + "line_number": 179 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "5b7d9816c719fdb6d5e62504ea054d5149290cbc", + "hashed_secret": "3c4b844fe8503fd74005d98dbbdc7090e387e769", "is_verified": false, - "line_number": 143 + "line_number": 183 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "2699bc23d4412ad3a63ae7e7f25243da483c9c8f", + "hashed_secret": "1eaa158cc276d54aaf77f7ee86dee8d25bb6984c", "is_verified": false, - "line_number": 146 + "line_number": 187 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "339956b59b859d7fe0d842ce1ba66376c6ca14b2", + "hashed_secret": "7c71b497f95ef18ed78a180c16792a9d74037ba0", "is_verified": false, - "line_number": 150 + "line_number": 191 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "f9344f3072e4b453d79f2b0eea6fa794c97bcf72", + "hashed_secret": "4b2e9289be86f796bdd365f64bd90a4dfe836212", "is_verified": false, - "line_number": 159 + "line_number": 195 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "ad1661029ac9560173599e047ce7f7eb1b8af588", + "hashed_secret": "af58d360235c3f370331a8b77d05c1adf2f8bf1c", "is_verified": false, - "line_number": 163 + "line_number": 199 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "3fffb80407809840e18f26a834a580338ffccdd1", + "hashed_secret": "9069fa4b87c2ed0ea454f34fcab8b0323c55adb2", "is_verified": false, - "line_number": 167 + "line_number": 203 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "fc7b2f07f919c3f8e20aa78a1756d2bcca252fb9", + "hashed_secret": "16b3ac75a5da3b885acfe819d1d13eba86f0528d", "is_verified": false, - "line_number": 171 + "line_number": 207 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "12526461adec30eba58c67d7f120b1724f4d1524", + "hashed_secret": "b7a9832e3241e8409dae0df9dac34e7edc6a0950", "is_verified": false, - "line_number": 174 + "line_number": 210 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "3cb8e7a708267691af8530db673cae1ad0578392", + "hashed_secret": "c0b61c8fdf6e7411a84990067f820968124df5d6", "is_verified": false, - "line_number": 178 + "line_number": 219 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "6c88ecf20f9dc070ee18f0cf59f58b0025f6ec55", + "hashed_secret": "08365dea5bc64be9f6ad1faf30ee2181c9e627fc", "is_verified": false, - "line_number": 182 + "line_number": 223 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "c0f64a532897bbd5497996fdfec7cfcd087540ce", + "hashed_secret": "359ac5bd61cb9c32b418f71877fad37f3f8cfd0e", "is_verified": false, - "line_number": 186 + "line_number": 227 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "b58ece1a23c6b414412378abceafd8549e609789", + "hashed_secret": "fd5633bb2fb1d33af5645b12dfdfd9d541c4cde1", "is_verified": false, - "line_number": 189 + "line_number": 231 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "d7a9375deefa4009a0dbeb9c84bac3c912d10189", + "hashed_secret": "d3541691a358a503965df61d03a03b145283e1f6", "is_verified": false, - "line_number": 193 + "line_number": 235 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "a12ae282ce5c351ca57ebcbeb36ef4d6d56aa22e", + "hashed_secret": "cf9d8563e81a99bb393c8ce3dedee23909dc0652", "is_verified": false, - "line_number": 196 + "line_number": 239 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "7c235e480345a8563b9b7348d8b568bd51bf3814", + "hashed_secret": "cbf41ed160d723d2d3e9b3a7efca841b6bcf6f88", "is_verified": false, - "line_number": 199 + "line_number": 243 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "935965ba7f384931bf702cbcecb35d13aadfa81a", + "hashed_secret": "5cb278a68156ae376bea48fb0f872cbc5dfd0a63", "is_verified": false, - "line_number": 203 + "line_number": 247 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "58ca0ef615f18c001be473ebab47d6424f35a983", + "hashed_secret": "7d5266ed941af1b1d15330dbd1d3a875c4bc9c81", "is_verified": false, - "line_number": 206 + "line_number": 251 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "7d34aa04ab2275cecd3f396f230993d3ea5b2b9c", + "hashed_secret": "dfce026d4d3a3c07f4b1217d9f8218c7a659648c", "is_verified": false, - "line_number": 210 + "line_number": 255 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "9c422f6dfca5f8acb3d3923f7aed45865b8e1168", + "hashed_secret": "cf4f01031348b40c6f929002bff27a18005f34cc", "is_verified": false, - "line_number": 214 + "line_number": 261 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "184476d985c0d799b82544165cb4d456129f594c", + "hashed_secret": "8319864fae2ce720a9588f890930b4dd3f736a4f", "is_verified": false, - "line_number": 219 + "line_number": 265 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "371ca38e7acde2cb9e7c1a8bea0355722ff047cf", + "hashed_secret": "96393754ecdf525c798560bfe0e751502aa16dd1", "is_verified": false, - "line_number": 223 + "line_number": 269 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "1e074256695d27dc0bea46d8da0b7d078ac360b9", + "hashed_secret": "6302a9750ba626bf9d98ea2b93c8044c2c13dd03", "is_verified": false, - "line_number": 227 + "line_number": 273 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "086b47ff4b09d5daf48fc11a12842df114051a64", + "hashed_secret": "88ca4f0d28c7143e72cc9cfecaf9901c26711cb5", "is_verified": false, - "line_number": 230 + "line_number": 277 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "e9ce08fb0922eb7b6fd8078a79f948726b9b756e", + "hashed_secret": "3a83d8726f7ee64e9aa8f886e95ac3c9d468d929", "is_verified": false, - "line_number": 234 + "line_number": 281 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "d5ec021cbb92c3486e38fac176f4f59a6c1d92e8", + "hashed_secret": "7bd7cab0500b448b5af6034691432c96b964a4c8", "is_verified": false, - "line_number": 238 + "line_number": 286 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "0168f03d6f1748d5d7ae624a93f711333b69d01c", + "hashed_secret": "3c07010114b3f8a05c537e24dbca7494bcf3c0a4", "is_verified": false, - "line_number": 242 + "line_number": 291 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "8f95bdf43361cffe6720264f402b5636239f6367", + "hashed_secret": "6345a5a64b6e2e20c37de7f332495c74b7e28b8b", "is_verified": false, - "line_number": 246 + "line_number": 296 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "ba64c6f076ce2e5ab5a5995d919407238ffd2738", + "hashed_secret": "3c39819cbcfb00ae8e9393a2055d140e03e87570", "is_verified": false, - "line_number": 250 + "line_number": 301 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "ab9483e4e6f3f98ffafe089718961922a6f24f6f", + "hashed_secret": "064abd6b6c5b30e71e41ce91c776a7306b0b3e96", "is_verified": false, - "line_number": 257 + "line_number": 307 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "abfcd37942c7aae783436ada04a0fd9289a56963", + "hashed_secret": "eced9884adaa31fbecade795c3ccd81df9ba9fcc", "is_verified": false, - "line_number": 262 + "line_number": 313 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "769a144538cf14ab88cc6c71e969d11a3a629897", + "hashed_secret": "b2fbdf7c2e16e91a406593fec247dad23b5efbf8", "is_verified": false, - "line_number": 267 + "line_number": 318 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "e7c4ee17ab711302f98d915dd0b266cd2d1063e5", + "hashed_secret": "37017cf4179b6c3178bb98a0627c1a68ccd3e514", "is_verified": false, - "line_number": 272 + "line_number": 323 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "4e31b7e578616be36866efeb9e637388374b913c", + "hashed_secret": "a3d57e425e9c3b5ab0b18abe7b8621625cc30c22", "is_verified": false, - "line_number": 275 + "line_number": 329 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "6a2e29f49d744c31da1e46102b652a3e92fb6e85", + "hashed_secret": "d796b7b26f7703279b30e1ce28d78e9b21aa7682", "is_verified": false, - "line_number": 278 + "line_number": 334 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "36bbe0af0706767b432067b56c97cbe9f34cdafb", + "hashed_secret": "8eabb20032175f899539869abd92d947b1089edf", "is_verified": false, - "line_number": 281 + "line_number": 339 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "53906794cdf2b395864474037b17b8739df2a8a2", + "hashed_secret": "020015c71e54dae0a6e3c559389a0f833b37c586", "is_verified": false, - "line_number": 285 + "line_number": 344 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "73f99321c1d31c19b059d58bf503320edb5ca0b8", + "hashed_secret": "16d2494dcd526d2ba248b35aef5636b20af470b5", "is_verified": false, - "line_number": 288 + "line_number": 349 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "81a165cc6dfc1c5b2a498fefdd6a0774232063b9", + "hashed_secret": "14b6efeff14f789b722c80b84a350f8e81a98866", "is_verified": false, - "line_number": 292 + "line_number": 354 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "8ac8e759308be486fa9eb1fcadf99defd39eab96", + "hashed_secret": "e883069b4b8d57f17deab7c65aa5ae60f9c7e454", "is_verified": false, - "line_number": 296 + "line_number": 359 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "805d9656080308c11cde7cb7f0310e12c7e60c48", + "hashed_secret": "7dc4aaf477bcf07897d462bdbf274b1108e666ce", "is_verified": false, - "line_number": 299 + "line_number": 365 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "9876fb8b9d0408d479d0c6d650987659c400d6d8", + "hashed_secret": "b49e53f8f31bace64c63850924b7d251800316e6", "is_verified": false, - "line_number": 302 + "line_number": 371 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "1ac881fcbb09fad5c8b422aa517c10f8aae9c8b0", + "hashed_secret": "261bb1dcc263df7ecdd01a0c01dadf24d04b170e", "is_verified": false, - "line_number": 306 + "line_number": 377 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "6c8698ec180cb58d9c2baa683e3a57c1eb18e2f3", + "hashed_secret": "8a3e5ca7ff5701e917511e537ae08cf06085e3ca", "is_verified": false, - "line_number": 310 + "line_number": 381 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "3c48660b66db6687c2ba1b7ffbac279614eead6e", + "hashed_secret": "180e848f306ee232593ddf09bf8f13cb01648197", "is_verified": false, - "line_number": 313 + "line_number": 385 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "5a485a4915f94231f9dc5b355ddda367442ba450", + "hashed_secret": "76a9ef26365296802b842dcf52bb19db8f769d72", "is_verified": false, - "line_number": 317 + "line_number": 389 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "19a998b1bf7031d19515cd35f049a7a6aa7dd1b3", + "hashed_secret": "1679d26697c0e20683a6e4f4fd68326aeb263daf", "is_verified": false, - "line_number": 321 + "line_number": 392 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "ea4d705920dea9e9007f7ffd977007d97d93720a", + "hashed_secret": "88fabb822ea5729f32cb8354ab467dacc9345816", "is_verified": false, - "line_number": 325 + "line_number": 395 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "dba7a636ef0b7f00cb8c1cfce617db1201444012", + "hashed_secret": "83301cba4927a257dd23354ef8d909cff24e2f5f", "is_verified": false, - "line_number": 328 + "line_number": 398 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "e1e1f7cc95ca09bf7be57e2d1b3859cba8463771", + "hashed_secret": "3498aef029b4f05bd100faaebda10014f51186b6", "is_verified": false, - "line_number": 331 + "line_number": 401 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "dc84bd66761f714927258320c0bb8dae84587827", + "hashed_secret": "b62ae999218390b924a5304938e497fd61ec4e66", "is_verified": false, - "line_number": 334 + "line_number": 404 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "da4bd9dac4854359566dbf94ac41d1f72d544ff7", + "hashed_secret": "1119596e8df549d5d1b2e83f34912cc0c84222d5", "is_verified": false, - "line_number": 338 + "line_number": 408 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "305d2d3066f6aae957badef6f31b6ba58384e249", + "hashed_secret": "c1d423d94eec2217b76cc22b4dde6962ba834b60", "is_verified": false, - "line_number": 343 + "line_number": 412 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "9534af6b0b48dbc429883be3937b26971ec8c382", + "hashed_secret": "758d60585bf3ac6800f37fddf85ea4fa3fb65c68", "is_verified": false, - "line_number": 347 + "line_number": 416 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "bfc272708c3e80f01cf42b142b29ebd1e0d208dd", + "hashed_secret": "3902fb6c06f5e5f90096da7e33d1194f4f98e5ed", "is_verified": false, - "line_number": 351 + "line_number": 420 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "5fcf66d7d8de78c2c73c953b5f9ff3de26e26f11", + "hashed_secret": "d36f04dc1793ec4cb618b69a9cd415b7555bb576", "is_verified": false, - "line_number": 354 + "line_number": 429 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "e756cf3ddd9e3088e7ec699b577f27138f3230e6", + "hashed_secret": "1169528db11b5937c32eb5ac986bec3327dc3d98", "is_verified": false, - "line_number": 357 + "line_number": 433 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "bdaf8b094715fef6fc9d26c411bc0f51e1f4d6bd", + "hashed_secret": "e39be1ccb3813c73523ed6636877fbf57f8a2615", "is_verified": false, - "line_number": 361 + "line_number": 437 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "4c8a960439864672cef22edf28a40c8a3749ed68", + "hashed_secret": "71b230e3fbe088691076ece7cd764ab80fa8537b", "is_verified": false, - "line_number": 365 + "line_number": 441 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "69e4418b2ffb487c86b50465bb7a8dd539577c3f", + "hashed_secret": "84dba321026d86db66b9f7f1c17b74d76bb90bc8", "is_verified": false, - "line_number": 369 + "line_number": 445 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "36a41bfa336a40f390233bcb3edba75b423c0ac5", + "hashed_secret": "93ddeebaa35c6f0b01a9c74fe112c0cbbd747582", "is_verified": false, - "line_number": 373 + "line_number": 449 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "c936ceac94d4e43d2f734b4b8b8231cb368458e0", + "hashed_secret": "faf75d9cb018979412a1b7debe6ed8508bc9ded0", "is_verified": false, - "line_number": 377 + "line_number": 453 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "d77ed2993e73b86fa8d1ac3ddc7f5849bdee137d", + "hashed_secret": "f099f09661dfdbd2e8b725d48b333710210cf2d4", "is_verified": false, - "line_number": 382 + "line_number": 457 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "798f9aebdd4bf85e0bdb03faad72aee2708a58c3", + "hashed_secret": "281048480df66504c7146f6502ae40d2e4551326", "is_verified": false, - "line_number": 386 + "line_number": 461 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "2812940b256983837d13fa07052a0e77033ef593", + "hashed_secret": "7d0516db23602e85bfd4ee968d0ed9d0fede2174", "is_verified": false, - "line_number": 390 + "line_number": 470 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "47f61db9968d9147373f9919fb2d39f044af96d4", + "hashed_secret": "726ea4ba810a13dc100b7d17c024418a5c57ebff", "is_verified": false, - "line_number": 393 + "line_number": 474 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "d76d4817cb196adc5f077a2c38ad1fef350afc25", + "hashed_secret": "4a6b8e2be6e08dfcba96ed7d7d379786e4541f9f", "is_verified": false, - "line_number": 397 + "line_number": 478 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "fd979750316587a434d421119fe616d5325ac2fb", + "hashed_secret": "1c62c8ae36f52c10955b4f8cbeafe314bed2e5bf", "is_verified": false, - "line_number": 401 + "line_number": 482 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "870edf2e3d49cca58020ed671229678252aab029", + "hashed_secret": "5f3cafb0c5b85a07a0b7ef7cb76ee4e0351ef5fb", "is_verified": false, - "line_number": 405 + "line_number": 486 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "f517feec4703cacc2629b34a71b6c4f71dcaaacb", + "hashed_secret": "c61a851ad4eba437a36854711f9cd9416ba70511", "is_verified": false, - "line_number": 409 + "line_number": 490 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "2e2f318d68a1eda1299228bc37e598ff282bd7b5", + "hashed_secret": "ce215c0c17429bafc7da01b52f9da7210bcf1a7e", "is_verified": false, - "line_number": 413 + "line_number": 494 }, { "type": "Base64 High Entropy String", "filename": "pnpm-lock.yaml", - "hashed_secret": "5888d1b730d17d304b51d9ac433812c153d85b55", + "hashed_secret": "8733ba82d33885a92ca3009eb6ba61e505a1ee9a", + "is_verified": false, + "line_number": 498 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "3e3522c83d724aa4133ee6bb6d627e4390be31a5", + "is_verified": false, + "line_number": 501 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "9d1b9dbec9f048e048f4dd38591947ca7deff5dd", + "is_verified": false, + "line_number": 504 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "f60c00cacddf514ad090fbde7005d8721adc0b89", + "is_verified": false, + "line_number": 508 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "b0e9cbf320ebe74507bcdaae3f0b40740459a092", + "is_verified": false, + "line_number": 511 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "90159a3d0b766d140d8e1382bddf0a5393d39060", + "is_verified": false, + "line_number": 514 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "59129b1a135b8b7d85991f4e41356c2cbac07ff6", + "is_verified": false, + "line_number": 518 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "c563399a1fbd03648cbc2b34ad64f637337d9e2c", + "is_verified": false, + "line_number": 522 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "9c5ef87c2f3d32f07f5cdc4e625c599bd5952518", + "is_verified": false, + "line_number": 526 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "ebed4bcc0633208a30eb5a5dbfeaa7364612a05d", + "is_verified": false, + "line_number": 530 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "9a1373fb8bc6343a2626079c5c977463daea3541", + "is_verified": false, + "line_number": 533 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "e768776e74a3cb5645d538a9bd074c78e7fb52b3", + "is_verified": false, + "line_number": 537 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "150a1608ffd7395d510860bc11f1b69b49ff015a", + "is_verified": false, + "line_number": 541 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "be9f24635e32f68141a9ae50e377a146bdc3fda5", + "is_verified": false, + "line_number": 545 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "0c389b15ae6e0c8d0bb1355bb3a83ed3134d3d87", + "is_verified": false, + "line_number": 549 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "44f6ecf2b3e9f69c4ae7a6696043be85a8d31180", + "is_verified": false, + "line_number": 553 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "887c43e1afae850fe0fd566b4c14a2ee4f15a6a0", + "is_verified": false, + "line_number": 556 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "e5c8903b90215138f5dfe1b601f2bad2ce640bd0", + "is_verified": false, + "line_number": 559 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "cfe8f58d96a4e6785caceca41052111098e942a6", + "is_verified": false, + "line_number": 562 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "883ffddc90f7adb0b2e567704e0e2e7c9ce772e7", + "is_verified": false, + "line_number": 565 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "6702a38277bc3118df52a8ae131bf8606eb21775", + "is_verified": false, + "line_number": 568 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "0a440a4af470afd5385d77ec40dadb271c54ad9a", + "is_verified": false, + "line_number": 571 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "6d3004f2923cb4359c1882806a9c1d814c13af26", + "is_verified": false, + "line_number": 574 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "68a26f4b13317c4169894a018f9d51028b8612e4", + "is_verified": false, + "line_number": 577 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "982840f567b3ee74aff466902d0109985bd827d1", + "is_verified": false, + "line_number": 581 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "ffd97f381f08ec805636c2cd5cfc2d8d0aa96416", + "is_verified": false, + "line_number": 585 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "8d784fb162cd0621bf5f542ffdcbbe4baf32a52b", + "is_verified": false, + "line_number": 589 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "d1588bfd42b83560ae6b972c114c0453d4974fa2", + "is_verified": false, + "line_number": 593 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "a6af43c0f344c9efcc9ad84ba0caca0df875a6e9", + "is_verified": false, + "line_number": 597 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "b629f637c3fd776d32d7d67eece9e425fb601b50", + "is_verified": false, + "line_number": 601 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "7a3f264d86f9e10f39a39b9a3edfc1053a241e08", + "is_verified": false, + "line_number": 605 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "f80a167c98a23a4060f6c75da5123c727346c256", + "is_verified": false, + "line_number": 609 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "a700bbda5a2512aeeb9de95a4d7ba00b3c46fd49", + "is_verified": false, + "line_number": 613 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "0209c5b09ad01670e52e181ef5d41eca0943cad5", + "is_verified": false, + "line_number": 617 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "851d30aeb3679f5792876eb94a5713707fe21617", + "is_verified": false, + "line_number": 621 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "23025ef89ab446dacd9b4cd6228bf35dfc2035b5", + "is_verified": false, + "line_number": 625 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "7c2e8a1c5ea0792c6bfa87b0950ae7556548e811", + "is_verified": false, + "line_number": 629 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "5decf31552205128a5bec73dafbbc60076d83fe5", + "is_verified": false, + "line_number": 633 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "e4bf3cb0634d9f1dcc8a38ffd7e7da729151ce44", + "is_verified": false, + "line_number": 637 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "b7fd9626311f8df9010a85c07f36cda6a43340c9", + "is_verified": false, + "line_number": 641 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "22c8094b69803debe48250a71fc7d687e27a099b", + "is_verified": false, + "line_number": 645 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "fc69ba66a483758fc95de0db25888fb958adab1d", + "is_verified": false, + "line_number": 649 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "915eb4947de3bda537f8db8813db5d822fa1af5d", + "is_verified": false, + "line_number": 653 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "480d947b28c6ccf1d34fbf41019cf0567e1e33d9", + "is_verified": false, + "line_number": 657 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "64e95ccca3287b67e2a2025abe6dbe7f64a73307", + "is_verified": false, + "line_number": 661 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "6ed97008c8de9b0494fbf51162189be6b90af570", + "is_verified": false, + "line_number": 665 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "3fc90cffc2660cb2c39c8655dcd1cd77e5035d6b", + "is_verified": false, + "line_number": 669 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "5892a4fc721cf0399187557a2b10ba432c4b391e", + "is_verified": false, + "line_number": 673 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "38b71c1dc0c8553d1bddac7945e70944ce544f10", + "is_verified": false, + "line_number": 677 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "2c9b24e89edd99e1b468652021f51a4f30cf4216", + "is_verified": false, + "line_number": 681 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "347f6ff86a369911c3c191173c6df937ac5e5b2c", + "is_verified": false, + "line_number": 685 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "cf17c3b458333ad388c8317f2caeca2966ce7d23", + "is_verified": false, + "line_number": 689 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "cf358058980455d9947fbb5c492b5a0652bf9d14", + "is_verified": false, + "line_number": 693 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "cbd49e0312eb722452c2da3650b7943d03609827", + "is_verified": false, + "line_number": 697 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "4c3dcbb24ef2382f16bc7498fd518719550858eb", + "is_verified": false, + "line_number": 701 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "c9bccd4482f52f6f00d6f88da721343f0aeab853", + "is_verified": false, + "line_number": 705 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "bf09c5d19946e9bd24bad562b9ae39a77ad5e194", + "is_verified": false, + "line_number": 709 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "1cec218901a8be693ac4f9f1368783bbf72461e7", + "is_verified": false, + "line_number": 713 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "3685f4f974ff2da79c75707e6f7d3b93cfc0f032", + "is_verified": false, + "line_number": 717 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "114b4ddafa33fa22a84822b72d952aa6b0bfba51", + "is_verified": false, + "line_number": 721 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "3b1b25fa7f07156bde481aed7b6c8624e0bbd7d3", + "is_verified": false, + "line_number": 725 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "5a4fe347d2d71e19db06d4b4125195688e2341bb", + "is_verified": false, + "line_number": 729 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "ea085792fac656981132365fe58a5d4c242f0396", + "is_verified": false, + "line_number": 733 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "6de00808379370b41e8e577e4f98e1771b7a78ac", + "is_verified": false, + "line_number": 737 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "16dafeb8a2e1a19f870fbb765abe7dd3454aa0b2", + "is_verified": false, + "line_number": 741 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "734c84474c873defd372704aa9b3504520e4b632", + "is_verified": false, + "line_number": 745 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "23980ce90dce595369dff677108c4861655709ec", + "is_verified": false, + "line_number": 749 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "50a0bc6ed4bd15cd54a9ef3e744d9e367b1ee0b2", + "is_verified": false, + "line_number": 752 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "f161e2b04f8faa8a514998b846300c0e52e3dde4", + "is_verified": false, + "line_number": 755 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "21a255bacab0430cf0dd455985ab2ac363136cdb", + "is_verified": false, + "line_number": 758 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "146791ae0b950340d17de1ffbda27158f3e8be1c", + "is_verified": false, + "line_number": 761 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "92a05cbfbf6f084203e283406bc9cc8345d77b40", + "is_verified": false, + "line_number": 764 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "c554198dff4b9d03e4fed41f9f60ab0f15c68690", + "is_verified": false, + "line_number": 767 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "d44a64c0233f7f71e6ffa1c3f65dcb001fa2dbe4", + "is_verified": false, + "line_number": 770 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "a3d0017933e859919c4c84d59093f79837ca3cd5", + "is_verified": false, + "line_number": 773 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "959766c6a6b6cdf60c93ebc380814ffaa67d347c", + "is_verified": false, + "line_number": 776 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "d3752c4bb2cbe605385ab0dc0abfbf416925518a", + "is_verified": false, + "line_number": 779 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "59bff97e344b82baf7bddd471a42314fff50d2e6", + "is_verified": false, + "line_number": 782 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "6333c496a262346ec033c10a9da94bcaeaab10b3", + "is_verified": false, + "line_number": 785 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "3b3fdad4fa80ae8473e0304fe52e0cd272ef2a11", + "is_verified": false, + "line_number": 788 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "8d73db03d596fc2383cce18710ce6efa95a1543c", + "is_verified": false, + "line_number": 791 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "51a7d1867fb6fa2cd6d3cbff335ae66d1c759ad7", + "is_verified": false, + "line_number": 794 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "d9ea4a68b988aca799b7049c045c05d7f5eb839a", + "is_verified": false, + "line_number": 797 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "8842ad0c2b8591c188d31740af8c97f7497952f5", + "is_verified": false, + "line_number": 800 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "843f6adc83e5bab548b2c1780b57d389041c7960", + "is_verified": false, + "line_number": 805 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "0bb042b843573172471a24b57b84d54ba2ae20a7", + "is_verified": false, + "line_number": 810 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "24a26fde2915eb83aeb5b58047c67633448aadcc", + "is_verified": false, + "line_number": 815 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "14bbf3e2247b687a2073aeedfe1a1bb199c7cd5f", + "is_verified": false, + "line_number": 820 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "ba3151c71df586ac67f0e20bb86af1f1841c695d", + "is_verified": false, + "line_number": 825 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "5a01aaedf6ce0250d934af8db3d9c5d37b3cfa6f", + "is_verified": false, + "line_number": 830 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "6f2cdb3f3bb55a86fc6718198490e5de840d6cc3", + "is_verified": false, + "line_number": 835 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "e4bb77220eb431a8e23d0448d352aa6798396f49", + "is_verified": false, + "line_number": 840 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "8b907cff5099cbd0c2dc3b29fc93b875d267d846", + "is_verified": false, + "line_number": 845 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "fdad62357203dd309d14a7656ef638dddfc6a4f4", + "is_verified": false, + "line_number": 850 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "dce5179f9c57f3995ad68bc6f7cdb0f402344020", + "is_verified": false, + "line_number": 855 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "e431f461f14ae4aa92ca9a304256467d315385d6", + "is_verified": false, + "line_number": 860 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "5a5c88a7b7a3cbb7d79e3c4eb272e2a49fc4f99f", + "is_verified": false, + "line_number": 865 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "d8ad402d1afd4eebda4d52652a5b1285a787d171", + "is_verified": false, + "line_number": 870 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "9b4737273b58c6b6b505f753a93507043228b137", + "is_verified": false, + "line_number": 875 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "29338d4886dd68d07f58cdc0371a13c35922a199", + "is_verified": false, + "line_number": 880 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "f6c2ddf97f92d4d8afee3ab85e40c99943258b41", + "is_verified": false, + "line_number": 885 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "3880ca9edb15902cf3bdbc88e95723f18a879154", + "is_verified": false, + "line_number": 890 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "3a3c0e6cef952fc58e9236a516048ccd707f81eb", + "is_verified": false, + "line_number": 895 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "9ffd4cfc9764a8a508ca76013e3d2e89db9f7ca1", + "is_verified": false, + "line_number": 899 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "35ca90e5fa9b87427a5ff8b3f333a7b7d45fc260", + "is_verified": false, + "line_number": 903 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "8dbe57e0fb77e02e3c6ce2fea324da52988cc557", + "is_verified": false, + "line_number": 907 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "d15b82853a984c45aab87399878c98082bac85ba", + "is_verified": false, + "line_number": 911 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "371ce323d657e273939fdad274085642a4da69de", + "is_verified": false, + "line_number": 915 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "c48ec3a65b99d02c0ed20187fa5d167008b4722f", + "is_verified": false, + "line_number": 919 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "69508e9f25ead6b12c7a762c93b2a5b9cb587d60", + "is_verified": false, + "line_number": 923 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "d2eb4e9361fd3c06052590c09d8b407f1753b2d4", + "is_verified": false, + "line_number": 927 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "22bd0abeadedbe4ac89a1415cff3788f50a4dc48", + "is_verified": false, + "line_number": 930 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "b3f138badb0793b86d2f4683a23346488db28125", + "is_verified": false, + "line_number": 933 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "ba921ad74c8f9cd5d3f3b1af724402623c2cb93f", + "is_verified": false, + "line_number": 952 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "8a7d019186146b9565b21f87ed4d7cf9b82ae77c", + "is_verified": false, + "line_number": 957 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "a908cc08ef7b2b82b091f46184f979afee56786a", + "is_verified": false, + "line_number": 963 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "181fd8ebef5e1c8ce567f05e1c165bb1ee12889d", + "is_verified": false, + "line_number": 967 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "f8e687b2ede1bb7958e58e75e4da62a0c8e37f0a", + "is_verified": false, + "line_number": 971 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "38436078a43755eb17951965d95c8e2cd529a04b", + "is_verified": false, + "line_number": 976 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "5b7cd7946ca5e56496386ea38016606310bfcf03", + "is_verified": false, + "line_number": 982 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "e0ed71a4c2836a6a251bef5e00b00401cfac286d", + "is_verified": false, + "line_number": 985 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "45d30be6e67551b2bd43cb6880ea327a0d9e8c10", + "is_verified": false, + "line_number": 989 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "4d797f74ec1be85c246106ccf1684fb857cad1c4", + "is_verified": false, + "line_number": 994 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "f83c0e1722151b136004f1e67d507feb727e52f6", + "is_verified": false, + "line_number": 997 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "301ec28c85c9d136ed8b90c2defb08d2d1dc6dc0", + "is_verified": false, + "line_number": 1000 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "5389eb7ce46c53e5d6adc57d5bafb1180870b814", + "is_verified": false, + "line_number": 1003 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "4cd454d960136b7b4f74cca3c12010ff2ebd7663", + "is_verified": false, + "line_number": 1007 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "45f942ddaa4e38db32deae06e421221a57bfa36e", + "is_verified": false, + "line_number": 1011 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "eaec307cdbac1f73cd6f87a413194cca794dc14c", + "is_verified": false, + "line_number": 1016 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "39706ecd1dc7f7807e0136ae3d22a50f9a7a6229", + "is_verified": false, + "line_number": 1020 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "38119d6755480fd81caed75382e6bcbb3685ca44", + "is_verified": false, + "line_number": 1023 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "7e0c8e7bebd3b26c7a99961ec3498c0448dcb08d", + "is_verified": false, + "line_number": 1026 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "accd3d42ae3e5a54f5c871a2c507ec4d5955911f", + "is_verified": false, + "line_number": 1030 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "6791035095fb16620100008d8bd175f89259e13f", + "is_verified": false, + "line_number": 1034 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "243457fabb02624bde70a29e7220095834e32171", + "is_verified": false, + "line_number": 1038 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "c0d0aae3b90810110f3ff7c2a447e9358df29c4c", + "is_verified": false, + "line_number": 1041 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "e763f903e7453d0cbc3ee08e62fd5ef520480d08", + "is_verified": false, + "line_number": 1045 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "5e5e944819f9ed1defbf3b9cf387214fb2f04d61", + "is_verified": false, + "line_number": 1051 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "f4bc56ad66486cf701c55579bcf67111c569f7bf", + "is_verified": false, + "line_number": 1055 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "e455b1a4260dd3fce7bd2e249781c0000f691634", + "is_verified": false, + "line_number": 1059 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "1b2b27157dffb66f57e2c3256f038d552e72b0e7", + "is_verified": false, + "line_number": 1063 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "43e69c85090264b5501c0964aaa3d048ee08fd37", + "is_verified": false, + "line_number": 1066 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "bda118bb6ac4d344bfd8dc748db3bd2039d09e06", + "is_verified": false, + "line_number": 1070 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "94729895770b7ab7572f90b68f3ed85a3ed06125", + "is_verified": false, + "line_number": 1074 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "d90742405c999ceb9f77a3fad82f3914b314618e", + "is_verified": false, + "line_number": 1078 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "d680dddb723a35dcd12ed6973f457a8dd4194eac", + "is_verified": false, + "line_number": 1082 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "4ed49acfbd180533265d2099f3043963c4a24443", + "is_verified": false, + "line_number": 1085 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "428e72f71455a785963f1df5f85c26c6d6ae519b", + "is_verified": false, + "line_number": 1089 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "4ebb417ccb84b97e3807fbc0690f7d5019974783", + "is_verified": false, + "line_number": 1092 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "98da97093cff2b0693b2f8ccc5b7e74758ae9f3e", + "is_verified": false, + "line_number": 1096 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "fde434c922321e6d79db61a86d5cb4c94633afed", + "is_verified": false, + "line_number": 1099 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "08c8ee0987f57add8cd4071488caef8ec431336a", + "is_verified": false, + "line_number": 1102 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "862951cd71d0412b5e52bae3e952725486a655b0", + "is_verified": false, + "line_number": 1105 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "2699bc23d4412ad3a63ae7e7f25243da483c9c8f", + "is_verified": false, + "line_number": 1109 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "d45062677e99f28b9a98f4b53e9af8f56b01bb10", + "is_verified": false, + "line_number": 1113 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "f9344f3072e4b453d79f2b0eea6fa794c97bcf72", + "is_verified": false, + "line_number": 1122 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "c9fd4bd7efca9da323127c3149bf081f4d677558", + "is_verified": false, + "line_number": 1126 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "c0c7aeed5e5a888b2aa3cdb739955fd27d25335e", + "is_verified": false, + "line_number": 1134 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "ad1661029ac9560173599e047ce7f7eb1b8af588", + "is_verified": false, + "line_number": 1138 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "3fffb80407809840e18f26a834a580338ffccdd1", + "is_verified": false, + "line_number": 1142 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "5cfbb495a02b599314652dfe8204aadb5cd48fbf", + "is_verified": false, + "line_number": 1146 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "da3e732bacfbc1b6e5ac2ec23fd153c53622b4bf", + "is_verified": false, + "line_number": 1150 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "11f5bb9b3e3917a15855dacd1c7c9e77804f5e7c", + "is_verified": false, + "line_number": 1153 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "e59823f0907ab15ce17c9ea2f498697f072d6f0b", + "is_verified": false, + "line_number": 1156 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "fc7b2f07f919c3f8e20aa78a1756d2bcca252fb9", + "is_verified": false, + "line_number": 1160 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "b3e321bc2eec51aaeb743b17220649f3d43443bf", + "is_verified": false, + "line_number": 1163 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "12526461adec30eba58c67d7f120b1724f4d1524", + "is_verified": false, + "line_number": 1166 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "79493155bf971702ff00e70c7bd59d1e56c9e40c", + "is_verified": false, + "line_number": 1170 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "4ce7c15280c45a3415db8c2feac1ee0ced1554ac", + "is_verified": false, + "line_number": 1174 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "3cb8e7a708267691af8530db673cae1ad0578392", + "is_verified": false, + "line_number": 1177 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "25f91f21090c20e53ec7b34dbd7db402baf227b6", + "is_verified": false, + "line_number": 1181 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "6c88ecf20f9dc070ee18f0cf59f58b0025f6ec55", + "is_verified": false, + "line_number": 1185 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "d8863c346b87ab9e420048134928a830cdb44e4f", + "is_verified": false, + "line_number": 1189 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "03d6fb388dd1b185129b14221f7127715822ece6", + "is_verified": false, + "line_number": 1194 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "4b14989c7328ad3949ab46c13655b0571d524c8f", + "is_verified": false, + "line_number": 1197 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "0271ba23ffedb48366d081c6b6067ad2d4fe18ba", + "is_verified": false, + "line_number": 1201 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "d3a376911f286f71fea736e51d58269aae0054a6", + "is_verified": false, + "line_number": 1205 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "c0f64a532897bbd5497996fdfec7cfcd087540ce", + "is_verified": false, + "line_number": 1209 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "b58ece1a23c6b414412378abceafd8549e609789", + "is_verified": false, + "line_number": 1212 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "fea0d9c5b0c53c41e6a0a961a49cccc170847120", + "is_verified": false, + "line_number": 1216 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "3914a32c0d1538072c42b0298d2ff41dbe527678", + "is_verified": false, + "line_number": 1219 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "5ac8fb493cd5a2d040c0a3e31bc20b7621543a4b", + "is_verified": false, + "line_number": 1222 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "02031683c4d321f8660ff8b2d0258362940c8a6d", + "is_verified": false, + "line_number": 1226 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "383566eee3c76b6e18064f3ebd645b26bbbfa121", + "is_verified": false, + "line_number": 1229 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "7c235e480345a8563b9b7348d8b568bd51bf3814", + "is_verified": false, + "line_number": 1232 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "0987509f1737d3768558ac71de7b481f4170f957", + "is_verified": false, + "line_number": 1236 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "6469b5be81ddc9f74fdb35bb646526db702e0f3d", + "is_verified": false, + "line_number": 1240 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "3aa0faf721de27687f7be4cc9ed4e61c745e6e44", + "is_verified": false, + "line_number": 1243 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "58ca0ef615f18c001be473ebab47d6424f35a983", + "is_verified": false, + "line_number": 1247 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "7d34aa04ab2275cecd3f396f230993d3ea5b2b9c", + "is_verified": false, + "line_number": 1251 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "373831427f7199408d11295085894b997d8537cf", + "is_verified": false, + "line_number": 1255 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "d950cd8ef510429f57fb282e4a437321ca86158c", + "is_verified": false, + "line_number": 1258 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "f5e09813aa79c07abdd4edd0469c3fad0a4ec10d", + "is_verified": false, + "line_number": 1263 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "184476d985c0d799b82544165cb4d456129f594c", + "is_verified": false, + "line_number": 1267 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "af5908e182714b9cf3e23d94666ad6aee26514a9", + "is_verified": false, + "line_number": 1271 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "12d7fe8a858191bcb754bf370fa775a409eac3c9", + "is_verified": false, + "line_number": 1275 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "371ca38e7acde2cb9e7c1a8bea0355722ff047cf", + "is_verified": false, + "line_number": 1279 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "75338bdf2d4880f1bafa0c8fc400c0e8c23433da", + "is_verified": false, + "line_number": 1283 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "0874a19c8ad4c9228975a7b93f0f23b5e59dd17c", + "is_verified": false, + "line_number": 1288 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "c82d951d495ea37952ebb0f3fe072f69f8b0f685", + "is_verified": false, + "line_number": 1293 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "1e074256695d27dc0bea46d8da0b7d078ac360b9", + "is_verified": false, + "line_number": 1297 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "4747f06de604a95bdabf28982c5ef1bc1a653eb8", + "is_verified": false, + "line_number": 1300 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "086b47ff4b09d5daf48fc11a12842df114051a64", + "is_verified": false, + "line_number": 1305 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "bbc5944e53f4bc00cb7c83d4ffdf2f3e477e2ee5", + "is_verified": false, + "line_number": 1309 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "935d0ebd64dba2fd3ec6ab430ddc9b692acef7d0", + "is_verified": false, + "line_number": 1312 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "f668db2ce6001bba5403f0fa041d3a1d7d4dd608", + "is_verified": false, + "line_number": 1316 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "e25be230742364f1ee60067a756da19dc7572126", + "is_verified": false, + "line_number": 1321 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "41e520378cd188868d9d2d6408c080f6fd5b37c9", + "is_verified": false, + "line_number": 1325 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "cf09cb791688fe019284bfdc362abc41918645a5", + "is_verified": false, + "line_number": 1329 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "28b39ec24e3005969658899da8df00018ced10cf", + "is_verified": false, + "line_number": 1332 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "e9ce08fb0922eb7b6fd8078a79f948726b9b756e", + "is_verified": false, + "line_number": 1335 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "d5ec021cbb92c3486e38fac176f4f59a6c1d92e8", + "is_verified": false, + "line_number": 1339 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "2d9bb7f81fd01f857fe9dd8bba1b0136ef524c49", + "is_verified": false, + "line_number": 1343 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "0168f03d6f1748d5d7ae624a93f711333b69d01c", + "is_verified": false, + "line_number": 1347 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "8f95bdf43361cffe6720264f402b5636239f6367", + "is_verified": false, + "line_number": 1351 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "7e775297f58b69533534e9a520c7b8b5bde51a0f", + "is_verified": false, + "line_number": 1355 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "4e5e279b264f80f6302507f3bc02028118a5381f", + "is_verified": false, + "line_number": 1359 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "e53d61332d1bf1be17d33eb4955ecd7fbed6bf02", + "is_verified": false, + "line_number": 1362 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "37f829b714b81fa242bfeb76fab61cc87effed86", + "is_verified": false, + "line_number": 1366 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "4dfc67bc966e2494008a5f5e346fca119afccb91", + "is_verified": false, + "line_number": 1370 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "3bff8900ed11d9495504774a20bd23285d6e9978", + "is_verified": false, + "line_number": 1374 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "1633ee6a9a0c91bca3ca3691bc73488dab661b3a", + "is_verified": false, + "line_number": 1378 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "f8e1856295ecbf3d0c5b858e3d6fb63209e97f03", + "is_verified": false, + "line_number": 1382 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "6f6a87915df1f8104ccdddd38709ad86cdba0597", + "is_verified": false, + "line_number": 1385 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "a1e534018ff0060496f28816c9252e3cdcaa932f", + "is_verified": false, + "line_number": 1389 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "8bdbbe571bbe91b8d18ab73e92154c39c847d261", + "is_verified": false, + "line_number": 1393 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "0c3517265dc2a2019fec742787d2840b004c290b", + "is_verified": false, + "line_number": 1403 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "02a1628e8c913354a7d5b4d3730d7aaa424eb802", + "is_verified": false, + "line_number": 1418 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "768c3634bc0373a51910cefa0c53c4c863be3db4", + "is_verified": false, + "line_number": 1422 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "8d13f4a2ee3d56b469cc0834ca45d3e8d98a36fb", + "is_verified": false, + "line_number": 1426 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "c8564f3983af0ef6e357eefaee096f56c5ad9fce", + "is_verified": false, + "line_number": 1430 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "6ae21ee3f7440a1244dca2a45b59fa14f7134004", + "is_verified": false, + "line_number": 1434 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "032b7f7b74e8f669f3e63c46bcbe69c6d4214cf6", + "is_verified": false, + "line_number": 1438 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "75248a795796195cb6296ce4b3558ba480d449b0", + "is_verified": false, + "line_number": 1442 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "71879c3fe6148c41155170e073a4cdf8f02376cd", + "is_verified": false, + "line_number": 1446 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "f425e70da5746b96a46d6a2d7226ce8d0ed314df", + "is_verified": false, + "line_number": 1450 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "27b02ebe9968e8bf56485bf9fb773360771db656", + "is_verified": false, + "line_number": 1454 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "082460674bd63d8d5582b9dbf1f7e05b72d6bc46", + "is_verified": false, + "line_number": 1463 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "efb1aad865d8ce36bf226874e9e72c64351679df", + "is_verified": false, + "line_number": 1467 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "570550054fda1f78e01774b2fb3d9e497b4343b4", + "is_verified": false, + "line_number": 1471 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "32d937b8e6b80bcda571a56ef5f52b7c9d79b0ac", + "is_verified": false, + "line_number": 1475 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "592a8e970d4cc7580ba1a58178dfa1ff2f2ac964", + "is_verified": false, + "line_number": 1479 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "c37bc4fa297176174d3b72abcc6137c52a2bd04a", + "is_verified": false, + "line_number": 1483 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "eed633f58b575061ee2600f43b6f4d9f1eda015f", + "is_verified": false, + "line_number": 1487 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "2da76d1339c1beb33694ec29cd8a94d44431e1a1", + "is_verified": false, + "line_number": 1491 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "bc1eedd39ceca1f09a5b3137ae5e2d1a5e9fa78e", + "is_verified": false, + "line_number": 1495 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "d8a9f5170fab35da1451ce4ce07f2695024dd906", + "is_verified": false, + "line_number": 1499 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "deb2f8d59cf57fe1853621c8ed5d299cc4b0a1bd", + "is_verified": false, + "line_number": 1503 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "5856bd4cb3a23981807d6e537408344c13ffaada", + "is_verified": false, + "line_number": 1513 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "ba0bfcc1d37270f26f2756a44e260f6b89eb0202", + "is_verified": false, + "line_number": 1516 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "3773d33c5207f8939a7a42e355daa3e28619cab1", + "is_verified": false, + "line_number": 1520 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "6509e4551b1182e3088963173328601657731a8f", + "is_verified": false, + "line_number": 1525 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "13a6d7162dacbd08cc72689a154b6b45a4e414b9", + "is_verified": false, + "line_number": 1532 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "abfcd37942c7aae783436ada04a0fd9289a56963", + "is_verified": false, + "line_number": 1537 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "e0f5af69bd4d49bfc18ee70fd619db952837a653", + "is_verified": false, + "line_number": 1542 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "497e90a54aa12b9814a01d9ba146b9ef2b2d43b9", + "is_verified": false, + "line_number": 1547 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "a38b32005305ff20174b03ddb42d76d3ef9cd7cb", + "is_verified": false, + "line_number": 1552 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "88d5de50147c0a55cf86a9c79b0002573f21febe", + "is_verified": false, + "line_number": 1555 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "4e31b7e578616be36866efeb9e637388374b913c", + "is_verified": false, + "line_number": 1560 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "3274795deb291199e248f6c3ca531235569dc2ae", + "is_verified": false, + "line_number": 1563 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "72308287f8c0e080820be69d27cd9810b9cd9de7", + "is_verified": false, + "line_number": 1566 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "1e94f147d0d24999498d3527047accfcd7cd2402", + "is_verified": false, + "line_number": 1570 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "1a1c6ff1fa188d12a42447ddfb3517580b729297", + "is_verified": false, + "line_number": 1573 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "e24a8cec071d865526b863f7dc34bd49679f7a29", + "is_verified": false, + "line_number": 1576 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "c5288a94b40ef119450c2006b11fa914e0432b4f", + "is_verified": false, + "line_number": 1580 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "36bbe0af0706767b432067b56c97cbe9f34cdafb", + "is_verified": false, + "line_number": 1583 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "87aff3c6c2e4719c7c068c625dc5c6bb833ab411", + "is_verified": false, + "line_number": 1587 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "7ed5906c17ddd4e8330c8475dac722175e5e67c4", + "is_verified": false, + "line_number": 1590 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "ad78dce4914916cc3c2a3cac28caade3e35a439a", + "is_verified": false, + "line_number": 1593 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "bed2be7dcefaad482d5b1215bb9c485bc1904f76", + "is_verified": false, + "line_number": 1596 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "cd662b440448baab7e093547626080b663ad57c5", + "is_verified": false, + "line_number": 1600 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "d8e88da9e8f3e4fc17769c89a3779ec33b8fe4e4", + "is_verified": false, + "line_number": 1603 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "a1a8574efb17b9eaeebd903cc476ba3cbba064ec", + "is_verified": false, + "line_number": 1606 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "53906794cdf2b395864474037b17b8739df2a8a2", + "is_verified": false, + "line_number": 1610 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "8c37396b226567b7c57113d19e43bbee0827f700", + "is_verified": false, + "line_number": 1613 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "2cf8bb850e018731465613b416d9603758dce6bb", + "is_verified": false, + "line_number": 1616 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "73f99321c1d31c19b059d58bf503320edb5ca0b8", + "is_verified": false, + "line_number": 1619 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "81a165cc6dfc1c5b2a498fefdd6a0774232063b9", + "is_verified": false, + "line_number": 1623 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "0913ac5bef0b5ee1e0619deee04bed578b2cb11a", + "is_verified": false, + "line_number": 1627 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "582c50b45fc46390a059b2ce9a99c0dd0685df12", + "is_verified": false, + "line_number": 1631 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "daf34481e73c91f0888018c377e845f49be248bc", + "is_verified": false, + "line_number": 1635 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "57b5925354dfce2fb9bdfcd1911e235f66884ac4", + "is_verified": false, + "line_number": 1638 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "8ac8e759308be486fa9eb1fcadf99defd39eab96", + "is_verified": false, + "line_number": 1642 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "0d829957741de8c35530f30026d098980047e3b2", + "is_verified": false, + "line_number": 1645 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "e7142097a5cce8cfe60e9c080b88bbbeb28f2a7a", + "is_verified": false, + "line_number": 1649 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "805d9656080308c11cde7cb7f0310e12c7e60c48", + "is_verified": false, + "line_number": 1653 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "8a1f84e5ca7afc17b2d6de721e5dae53450cf51b", + "is_verified": false, + "line_number": 1656 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "508ffd78a7522f83c630d98474d58ccf406bd25e", + "is_verified": false, + "line_number": 1661 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "68098bf4bc6873ec14d7ceb6ca8ae49988505c5c", + "is_verified": false, + "line_number": 1664 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "db24a96822d27d963dfd24104c32896bea3378e7", + "is_verified": false, + "line_number": 1667 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "7da567d952b9ce54116a82e9cc65adcc522e9cec", + "is_verified": false, + "line_number": 1670 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "0a68e003e521d8b996e1c07b3b5b2664de31fa44", + "is_verified": false, + "line_number": 1673 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "97d77287e5c1c07c7f96185e164752eca91c10dc", + "is_verified": false, + "line_number": 1677 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "38c97b06ca380ec6b537fb959634cd6042e47cea", + "is_verified": false, + "line_number": 1681 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "064b874f13a3779d7227de426e2646eaaf7d1b39", + "is_verified": false, + "line_number": 1684 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "81b62cf0de867735ebbc82930f3cb8f28ec9f7f0", + "is_verified": false, + "line_number": 1688 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "8f403af2e2a0b05b5124a389ca3d3e0b0730ac18", + "is_verified": false, + "line_number": 1692 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "95a1f1449148121ac6b0cdc457dc80b86eb16ef7", + "is_verified": false, + "line_number": 1696 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "d69c03a6c481565a321e7b90776918eaad16e532", + "is_verified": false, + "line_number": 1700 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "b1655f0436ed02542ddf34e65e9897e59633b752", + "is_verified": false, + "line_number": 1704 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "e9adfe8a333d45f4776fe0eab31608be5d7b6a7d", + "is_verified": false, + "line_number": 1708 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "1e2ab7a2fd9b6afcbe08afcb9dc652b76cf367d8", + "is_verified": false, + "line_number": 1712 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "c9a0f70271023a1b82d44b6544c32f834098c007", + "is_verified": false, + "line_number": 1716 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "55159cc007aeaf5fbf0746c70b85d9ddc16db6dd", + "is_verified": false, + "line_number": 1720 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "d9efbdf71c548a000ac469ddb800a566a21d9824", + "is_verified": false, + "line_number": 1723 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "533573708ed3eabce514432cd26d532218222659", + "is_verified": false, + "line_number": 1727 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "7fc906e877743ef9546f3151161d4fa48828c0dc", + "is_verified": false, + "line_number": 1731 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "1b6d7df51b5d3b60d36200961a9cc1885619e96b", + "is_verified": false, + "line_number": 1735 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "61ff5c8a702d8ffe28063c06487a3a5a0db2ed70", + "is_verified": false, + "line_number": 1739 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "6942521514db424f66a46a086146c8d65db76a9c", + "is_verified": false, + "line_number": 1743 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "4439cc784aca22e5cdf33ed6656f5b8b99eaa44d", + "is_verified": false, + "line_number": 1747 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "3bae03646f3110a32c0d619e0c5fb4a15d624dfb", + "is_verified": false, + "line_number": 1750 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "84cbfec871cf2b178ab90fcc1f442fa369e99a02", + "is_verified": false, + "line_number": 1754 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "dabcefa910bca0b7a0a6e2ee5b8ba0afef580da4", + "is_verified": false, + "line_number": 1758 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "a333e405c05cedb7d37ba5a38ad44f4d236fe04c", + "is_verified": false, + "line_number": 1762 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "67f9ea1991a197c0cdb47b4bd9ac366d818be2da", + "is_verified": false, + "line_number": 1766 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "5523373dbe051a38753a9cf27470715ff7baff91", + "is_verified": false, + "line_number": 1770 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "bd5bc1fcd769944ee0456c90de0e225282d4cbaa", + "is_verified": false, + "line_number": 1774 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "7410fa0f9ec078ee65600cf06cfe5f40899a43f2", + "is_verified": false, + "line_number": 1778 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "6c8698ec180cb58d9c2baa683e3a57c1eb18e2f3", + "is_verified": false, + "line_number": 1781 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "5a233e343e439f5c0b7dad5b0bb3aac587600408", + "is_verified": false, + "line_number": 1784 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "3c48660b66db6687c2ba1b7ffbac279614eead6e", + "is_verified": false, + "line_number": 1787 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "87311dfdbf66115926199b1b8860a64d6ecd82f8", + "is_verified": false, + "line_number": 1791 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "f241e44dd1421cd26ac78332db57217a61f50c94", + "is_verified": false, + "line_number": 1795 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "19a998b1bf7031d19515cd35f049a7a6aa7dd1b3", + "is_verified": false, + "line_number": 1799 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "ea4d705920dea9e9007f7ffd977007d97d93720a", + "is_verified": false, + "line_number": 1803 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "dba7a636ef0b7f00cb8c1cfce617db1201444012", + "is_verified": false, + "line_number": 1806 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "e1e1f7cc95ca09bf7be57e2d1b3859cba8463771", + "is_verified": false, + "line_number": 1809 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "dc84bd66761f714927258320c0bb8dae84587827", + "is_verified": false, + "line_number": 1812 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "34773e71019373ba971fa0265e3e52031b422881", + "is_verified": false, + "line_number": 1816 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "39ffec566bffcf8c829bbb54c2f3e06aadbccb15", + "is_verified": false, + "line_number": 1821 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "3b7990b0f82bc9bc6c4ec02744f9c02e76aac827", + "is_verified": false, + "line_number": 1826 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "9072ebcbad57a1ae4256c5ae37c1e7c7ae5325de", + "is_verified": false, + "line_number": 1830 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "aebd9b8412f3fba8781b7baf0ef5c23c7a1e3e27", + "is_verified": false, + "line_number": 1834 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "23423aec1fa9a6422ea8dbb0c73bd0174b2b38cf", + "is_verified": false, + "line_number": 1837 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "e0c6ae09dd70fa25056d591ada0ad32df0dfe873", + "is_verified": false, + "line_number": 1840 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "3479f0e3e92704b5afe32cade0c15e4cf17d2a2d", + "is_verified": false, + "line_number": 1844 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "305d2d3066f6aae957badef6f31b6ba58384e249", + "is_verified": false, + "line_number": 1848 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "a074015a2ea6680882630eb6afb3cd7c287e581f", + "is_verified": false, + "line_number": 1852 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "c44c74adebc4e9832e4ef9dacc60f36520f7e919", + "is_verified": false, + "line_number": 1855 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "8db956011899a12e5f3cd3b6451ca77c5896f94d", + "is_verified": false, + "line_number": 1858 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "04cf794e93b60fc66b2df9f8eeea36a4ae6399fe", + "is_verified": false, + "line_number": 1862 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "76f7a220b13a5c673c9a65d610548916d4fabb64", + "is_verified": false, + "line_number": 1866 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "b8e38fac2ae28f11ebab0c7ae8dc458221daa23a", + "is_verified": false, + "line_number": 1869 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "bfc272708c3e80f01cf42b142b29ebd1e0d208dd", + "is_verified": false, + "line_number": 1873 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "5fcf66d7d8de78c2c73c953b5f9ff3de26e26f11", + "is_verified": false, + "line_number": 1876 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "e756cf3ddd9e3088e7ec699b577f27138f3230e6", + "is_verified": false, + "line_number": 1879 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "37153fe39ca138fd330d60f5de222555ea66d3c1", + "is_verified": false, + "line_number": 1883 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "bdaf8b094715fef6fc9d26c411bc0f51e1f4d6bd", + "is_verified": false, + "line_number": 1887 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "d4f8d980c943fb306089e921db8c8afd41e8a906", + "is_verified": false, + "line_number": 1891 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "4c8a960439864672cef22edf28a40c8a3749ed68", + "is_verified": false, + "line_number": 1895 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "7268ef4cac1850afd5fa09179f14b9ba7945e998", + "is_verified": false, + "line_number": 1899 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "8c14ca426e18afd6aedb33f1e4af45d10ff480c9", + "is_verified": false, + "line_number": 1903 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "791f38c64d2c51c10e14db462464c2666734d875", + "is_verified": false, + "line_number": 1907 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "3e536139add3f70bf34712d57cf490dc9c631553", + "is_verified": false, + "line_number": 1911 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "75d6f57727a8b8e8f2a3b491c13e1cb662ec50e5", + "is_verified": false, + "line_number": 1915 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "69e4418b2ffb487c86b50465bb7a8dd539577c3f", + "is_verified": false, + "line_number": 1918 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "869a40b729083c41268e4ff73da71ad253b63821", + "is_verified": false, + "line_number": 1922 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "7bda02df3ca167acab815c2b56745666e52ef287", + "is_verified": false, + "line_number": 1926 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "ac89a1c44566548af25a362310fd0f5520667be0", + "is_verified": false, + "line_number": 1930 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "1528a569a2fd746c9f56d0c681950464f681bf88", + "is_verified": false, + "line_number": 1934 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "36a41bfa336a40f390233bcb3edba75b423c0ac5", + "is_verified": false, + "line_number": 1937 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "1cc398b37de6d2bb302f93b4bd4bfb2acd5cb569", + "is_verified": false, + "line_number": 1941 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "9bf200e3147a1d837839d6bf5b649716c1537cbb", + "is_verified": false, + "line_number": 1968 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "cf8769a85052b2fe5947cc828d2c35305e3cc2d4", + "is_verified": false, + "line_number": 1971 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "15722b2d85dd78abf0730839c0ebdfb5873a2da4", + "is_verified": false, + "line_number": 1975 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "61daef219a32046633e73ba8c3fefa7d4ab6b969", + "is_verified": false, + "line_number": 1979 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "daa0102a157ea04a79bf04855f98f04a0479667c", + "is_verified": false, + "line_number": 1983 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "ec839519d0e7cbd10ca796b3619320c33d573ffc", + "is_verified": false, + "line_number": 1989 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "56034273bc4e0d8253af113d543716093bed91d0", + "is_verified": false, + "line_number": 1996 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "6e052fe5b46f0af3010617109aaecb06e8a1f300", + "is_verified": false, + "line_number": 2001 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "8239574b4263502316ae4af75858c8537b8f9dc2", + "is_verified": false, + "line_number": 2006 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "0bebc605b06f69baf10e875299a9fe4f855d9653", + "is_verified": false, + "line_number": 2011 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "b9d6359fabc640786748fbc71eb7510582560090", + "is_verified": false, + "line_number": 2014 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "2a3c469cc37ae3286c10371b18113225f36ed78a", + "is_verified": false, + "line_number": 2019 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "d77ed2993e73b86fa8d1ac3ddc7f5849bdee137d", + "is_verified": false, + "line_number": 2022 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "798f9aebdd4bf85e0bdb03faad72aee2708a58c3", + "is_verified": false, + "line_number": 2026 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "a0a9f050339fb7dfc50f201b52645f99c719dc6e", + "is_verified": false, + "line_number": 2030 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "f2f138a5ead5a6a39c60b357eada0cb5a3ef1179", + "is_verified": false, + "line_number": 2033 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "8af5d8dc6608e85d5fa11a6e06ad79765fff659b", + "is_verified": false, + "line_number": 2039 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "4a1c248f3b2ff8b4ac7ee3bd67eafb7b69668f93", + "is_verified": false, + "line_number": 2043 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "f0257d02d23f53e799476af74cf0386375d4b6c2", + "is_verified": false, + "line_number": 2046 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "502ed3cb6d6f07c63a9a2ef33f92a2f3097cc722", + "is_verified": false, + "line_number": 2051 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "2812940b256983837d13fa07052a0e77033ef593", + "is_verified": false, + "line_number": 2054 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "47f61db9968d9147373f9919fb2d39f044af96d4", + "is_verified": false, + "line_number": 2057 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "c1907fc992b0e961b0c622ff8cd91edc36456c2a", + "is_verified": false, + "line_number": 2061 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "c1a38efe10a08cb343b44ec85e4f991fa64e0dc8", + "is_verified": false, + "line_number": 2065 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "b5acc3ebb6bebc72bf34bf1b65ca79c72c75f3c1", + "is_verified": false, + "line_number": 2068 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "d76d4817cb196adc5f077a2c38ad1fef350afc25", + "is_verified": false, + "line_number": 2072 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "fd979750316587a434d421119fe616d5325ac2fb", + "is_verified": false, + "line_number": 2076 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "c0665d268ee6985b4ce98c67b9c6c7502af11a25", + "is_verified": false, + "line_number": 2080 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "7db07f24357281a9cede362e507b81377ae14a60", + "is_verified": false, + "line_number": 2083 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "f517feec4703cacc2629b34a71b6c4f71dcaaacb", + "is_verified": false, + "line_number": 2088 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "5888d1b730d17d304b51d9ac433812c153d85b55", + "is_verified": false, + "line_number": 2092 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "bc001a988dc93a1d9f0b811c8b6f5ed71cd922e8", + "is_verified": false, + "line_number": 2096 + }, + { + "type": "Base64 High Entropy String", + "filename": "pnpm-lock.yaml", + "hashed_secret": "d9cc3c5b434e0414fd4a9a4a2fd5dfa0c81fffc3", "is_verified": false, - "line_number": 417 + "line_number": 2100 } ] }, - "generated_at": "2025-03-25T10:37:22Z" + "generated_at": "2026-08-06T10:45:22Z" } diff --git a/packages/orcabus-pipeline-test-utils/README.md b/packages/orcabus-pipeline-test-utils/README.md new file mode 100644 index 0000000..7d8800f --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/README.md @@ -0,0 +1,327 @@ +# orcabus-pipeline-test-utils + +Shared test utilities for OrcaBus Pipeline Orchestrator services. Provides pytest fixtures, +ASL validation, a Step Functions TestState API test harness, and post-deployment smoke +test utilities. + +## Installation + +```bash +# From a consuming service's pyproject.toml +pip install -e "path/to/platform-cdk-constructs/packages/orcabus-pipeline-test-utils" + +# Or as a git dependency +pip install "orcabus-pipeline-test-utils @ git+https://github.com/OrcaBus/platform-cdk-constructs.git#subdirectory=packages/orcabus-pipeline-test-utils" +``` + +Requires Python >= 3.12. + +## Modules + +| Module | Purpose | When it runs | +|--------|---------|--------------| +| `fixtures` | Moto-based AWS mocks, Lambda context, event builder | Pre-deployment (unit tests) | +| `asl_validation` | Structural validation of ASL JSON definitions | Pre-deployment (unit tests) | +| `sfn_teststate` | Step Functions TestState API integration tests | Pre-deployment (integration tests) | +| `smoke` | Live resource verification (Lambda, SFN, SSM) | Post-deployment (smoke tests) | + +## Pytest Plugin (auto-registered fixtures) + +When installed, the package registers a pytest plugin via the `pytest11` entry point. This +makes shared fixtures available to any test session automatically — no `conftest.py` imports +needed. + +### Available fixtures + +#### `mock_s3_client` / `mock_ssm_client` / `mock_secretsmanager_client` / `mock_events_client` + +Session-scoped moto-based boto3 clients for S3, SSM, Secrets Manager, and EventBridge. +Region defaults to `ap-southeast-2`. + +```python +def test_s3_upload(mock_s3_client): + mock_s3_client.create_bucket( + Bucket="test-bucket", + CreateBucketConfiguration={"LocationConstraint": "ap-southeast-2"}, + ) + mock_s3_client.put_object(Bucket="test-bucket", Key="data.json", Body=b"{}") + response = mock_s3_client.get_object(Bucket="test-bucket", Key="data.json") + assert response["Body"].read() == b"{}" +``` + +#### `lambda_context` + +Factory fixture that creates mock Lambda context objects with configurable attributes. + +```python +def test_handler(lambda_context): + ctx = lambda_context(function_name="my-function", memory_limit_in_mb=256) + result = handler({"key": "value"}, ctx) + assert result["statusCode"] == 200 +``` + +#### `event_builder` + +Factory fixture for building Lambda event payloads. + +```python +def test_handler(event_builder, lambda_context): + event = event_builder({"detail": {"sample_id": "SBJ00001"}}) + result = handler(event, lambda_context()) + assert result["statusCode"] == 200 +``` + +## ASL Validation + +Validates Amazon States Language JSON definitions for structural correctness without +requiring deployment or Docker. + +```python +import json +from orcabus_pipeline_test_utils.asl_validation.validator import ( + validate_asl_definition, + ValidationCategory, +) + +def test_state_machine_definition_valid(): + with open("step-functions-templates/my_workflow.asl.json") as f: + asl = json.load(f) + + result = validate_asl_definition(asl, file_path="my_workflow.asl.json") + assert result.category == ValidationCategory.SUCCESS, result.errors +``` + +Checks performed: +- Required top-level fields (`StartAt`, `States`) +- Valid state types (Task, Pass, Choice, Wait, Succeed, Fail, Parallel, Map) +- `StartAt` references an existing state +- `Next` fields reference existing states +- Choice state branch targets and `Default` reference existing states +- Parallel branch and Map ItemProcessor `StartAt` validation + +### Placeholder resolver + +Use `placeholder_resolver` to substitute `${__xxx__}` placeholders in ASL template +strings before parsing and validation: + +```python +import json +from orcabus_pipeline_test_utils.asl_validation.placeholder_resolver import resolve_placeholders + +with open("step-functions-templates/my_workflow.asl.json") as f: + raw_asl_content = f.read() + +resolved_content = resolve_placeholders(raw_asl_content) +asl = json.loads(resolved_content) +result = validate_asl_definition(asl) +``` + +### Reference checker + +Use `reference_checker` to verify that all Lambda ARN placeholders in your ASL +correspond to entries in your CDK lambda configuration map: + +```python +from orcabus_pipeline_test_utils.asl_validation.reference_checker import check_lambda_arn_references + +cdk_lambda_config = { + "lambdas": { + "my_handler": { + "placeholder": "${__my_handler_lambda_function_arn__}", + "entry": "app/lambdas/my_handler_py", + }, + } +} + +errors = check_lambda_arn_references(asl_definition, cdk_lambda_config) +assert errors == [] +``` + +## Step Functions TestState API (integration tests) + +Tests individual states in your Step Functions definitions using the AWS +[TestState API](https://docs.aws.amazon.com/step-functions/latest/apireference/API_TestState.html). +Supports mocked service integrations, chained state execution, and data flow assertions. + +### Testing a single state + +```python +from orcabus_pipeline_test_utils.sfn_teststate import TestStateClient, TestStateAssertion + +client = TestStateClient(role_arn="arn:aws:iam::123456789012:role/sfn-test-role") + +result = client.test_state( + definition=asl_definition, + input_data={"sample_id": "SBJ00001"}, + state_name="LaunchDragen", + mock_result={"job_id": "job-123", "status": "LAUNCHED"}, +) + +assertion = TestStateAssertion(result) +assert assertion.assert_succeeded().passed +assert assertion.assert_output({"job_id": "{% any_string %}", "status": "LAUNCHED"}).passed +assert assertion.assert_next_state("CheckStatus").passed +``` + +### Chaining states (execution path) + +```python +from orcabus_pipeline_test_utils.sfn_teststate import TestStateClient, PathAssertion + +client = TestStateClient(role_arn="arn:aws:iam::123456789012:role/sfn-test-role") + +results = client.test_path( + definition=asl_definition, + input_data={"sample_id": "SBJ00001"}, + state_mocks={ + "LaunchDragen": {"result": {"job_id": "job-123", "status": "LAUNCHED"}}, + "CheckStatus": {"result": {"status": "COMPLETE", "output_uri": "s3://results/"}}, + }, +) + +path = PathAssertion(results) +assert path.assert_path_succeeded().passed +assert path.assert_step_count(3).passed +assert path.assert_terminal_output({"status": "COMPLETE", "output_uri": "s3://results/"}).passed +``` + +### Data flow inspection + +```python +assertion = TestStateAssertion(result) +assert assertion.assert_data_flow( + after_input_path={"sample_id": "SBJ00001"}, + after_parameters={"FunctionName": "{% any_string %}", "Payload": {"sample_id": "SBJ00001"}}, +).passed +``` + +## Post-Deployment Smoke Tests + +Verify that deployed AWS resources are functional. These run **after** deployment and +require AWS credentials with appropriate permissions. + +### Lambda DryRun check + +Calls `Invoke(DryRun)` — validates permissions and configuration without executing code: + +```python +import boto3 +from orcabus_pipeline_test_utils.smoke.lambda_check import check_lambda_invocable + +session = boto3.Session() +result = check_lambda_invocable("my-function-name", session) +assert result.passed, f"{result.error_type}: {result.error_message}" +``` + +### State Machine check + +Calls `DescribeStateMachine` and verifies status is ACTIVE with a non-null definition: + +```python +from orcabus_pipeline_test_utils.smoke.sfn_check import check_state_machine_active + +result = check_state_machine_active( + "arn:aws:states:ap-southeast-2:123456789012:stateMachine:MyWorkflow", + session, +) +assert result.passed, f"{result.error_type}: {result.error_message}" +``` + +### SSM Parameter check + +Verifies SSM parameters exist and are readable: + +```python +from orcabus_pipeline_test_utils.smoke.ssm_check import check_ssm_parameters_exist + +results = check_ssm_parameters_exist( + ["/orcabus/config/api-url", "/orcabus/config/event-bus-name"], + session, +) +for r in results: + assert r.passed, f"{r.resource_name}: {r.error_message}" +``` + +### SmokeTestResult + +All smoke checks return `SmokeTestResult`: + +```python +@dataclass +class SmokeTestResult: + resource_name: str + resource_type: str # "lambda" | "state_machine" | "ssm_parameter" + passed: bool + error_type: str | None # "auth" | "config" | None + error_message: str | None +``` + +Error classification: +- `auth` — IAM/credential issues (AccessDeniedException, ExpiredTokenException) +- `config` — Resource doesn't exist or is misconfigured + +## Pipeline Integration + +### Pre-deployment tests (unitAppTestConfig) + +ASL validation and TestState API tests run in the pipeline's `unitAppTestConfig` CodeBuild +step, which executes before CDK synth. + +```typescript +unitAppTestConfig: { + installCommands: [ + ...DEFAULT_INSTALL_COMMANDS, + 'pip install -e "./app[test]"', + ], + command: [ + 'pytest app/step-functions-templates/tests/ -v --tb=short', + ], + partialBuildSpec: { + phases: { install: { 'runtime-versions': { nodejs: '22.x', python: '3.14' } } }, + version: '0.2', + }, +} +``` + +### Post-deployment tests (pipeline stage `post` step) + +Smoke tests run after a stage deploys. They require cross-account IAM permissions and +cannot use `unitIacTestConfig` or `unitAppTestConfig` (those run before any deployment). + +```typescript +// Add as a post-deployment step on a stage +cdkPipeline.addStage(betaStage, { + post: [ + new CodeBuildStep('SmokeTestBeta', { + commands: [ + 'pip install orcabus-pipeline-test-utils', + 'pytest tests/smoke/ -v --tb=short', + ], + rolePolicyStatements: [ + new PolicyStatement({ + actions: ['lambda:InvokeFunction'], + resources: ['arn:aws:lambda:ap-southeast-2:*:function:my-service-*'], + }), + new PolicyStatement({ + actions: ['states:DescribeStateMachine'], + resources: ['*'], + }), + new PolicyStatement({ + actions: ['ssm:GetParameter'], + resources: ['arn:aws:ssm:ap-southeast-2:*:parameter/orcabus/*'], + }), + ], + }), + ], +}); +``` + +## Development + +```bash +# Install in development mode +pip install -e ".[dev]" + +# Run the package's own tests +pytest tests/ -v +``` diff --git a/packages/orcabus-pipeline-test-utils/pyproject.toml b/packages/orcabus-pipeline-test-utils/pyproject.toml new file mode 100644 index 0000000..2873aaa --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "orcabus-pipeline-test-utils" +version = "0.1.0" +description = "Shared test utilities for OrcaBus Pipeline Orchestrator services" +readme = "README.md" +requires-python = ">=3.12" +license = "MIT" +authors = [ + { name = "OrcaBus Team" }, +] +dependencies = [ + "pytest>=7.4", + "moto[s3,ssm,secretsmanager,events,lambda]>=5.0", + "boto3>=1.34", + "jsonschema>=4.20", + "hypothesis>=6.0", + "boto3-stubs~=1.43.65", +] + +[project.entry-points.pytest11] +orcabus_pipeline_test_utils = "orcabus_pipeline_test_utils.conftest_plugin" + +[tool.hatch.build.targets.wheel] +packages = ["src/orcabus_pipeline_test_utils"] diff --git a/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/__init__.py b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/__init__.py new file mode 100644 index 0000000..5d5c90e --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/__init__.py @@ -0,0 +1,8 @@ +"""OrcaBus Pipeline Test Utilities. + +Shared test utilities package for OrcaBus Pipeline Orchestrator services. +Provides pytest fixtures, ASL validation, TestState API test harness, +and post-deployment smoke test utilities. +""" + +__version__ = "0.1.0" diff --git a/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/asl_validation/__init__.py b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/asl_validation/__init__.py new file mode 100644 index 0000000..4fdd195 --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/asl_validation/__init__.py @@ -0,0 +1,31 @@ +"""ASL (Amazon States Language) validation engine. + +Provides: +- validator: Structural validation of ASL definitions +- placeholder_resolver: ${__xxx__} placeholder substitution +- reference_checker: State and Lambda ARN reference validation +""" + +from orcabus_pipeline_test_utils.asl_validation.placeholder_resolver import ( + load_placeholder_map, + resolve_placeholders, +) +from orcabus_pipeline_test_utils.asl_validation.reference_checker import ( + check_lambda_arn_references, + check_state_references, +) +from orcabus_pipeline_test_utils.asl_validation.validator import ( + ValidationCategory, + ValidationResult, + validate_asl_definition, +) + +__all__ = [ + "ValidationCategory", + "ValidationResult", + "check_lambda_arn_references", + "check_state_references", + "load_placeholder_map", + "resolve_placeholders", + "validate_asl_definition", +] diff --git a/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/asl_validation/placeholder_resolver.py b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/asl_validation/placeholder_resolver.py new file mode 100644 index 0000000..c8fab4b --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/asl_validation/placeholder_resolver.py @@ -0,0 +1,187 @@ +"""Placeholder resolver for ASL templates. + +Resolves ${__xxx__} placeholders in ASL content with valid dummy ARNs +or user-provided placeholder maps. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + + +# Regex to match ${__xxx__} placeholders in ASL content +PLACEHOLDER_PATTERN = re.compile(r"\$\{__([a-z0-9_]+)__\}") + +# Default region and account used for dummy ARN generation +DEFAULT_REGION = "ap-southeast-2" +DEFAULT_ACCOUNT_ID = "123456789012" + + +def resolve_placeholders( + asl_content: str, + placeholder_map: dict[str, str] | None = None, +) -> str: + """Replace ${__xxx__} placeholders with valid dummy ARN values. + + If placeholder_map is None, auto-generates dummy ARNs based on + placeholder names (e.g., ${__lambda_function_arn__} -> + arn:aws:lambda:ap-southeast-2:123456789012:function:lambda_function). + + Args: + asl_content: Raw ASL JSON string containing placeholders. + placeholder_map: Optional mapping from full placeholder tokens + (e.g., "${__my_lambda_function_arn__}") to replacement values. + If None, dummy values are auto-generated from placeholder names. + + Returns: + ASL content string with all placeholders resolved. + """ + if placeholder_map is None: + placeholder_map = {} + + def _replace_match(match: re.Match[str]) -> str: + full_token = match.group(0) # e.g., ${__lambda_function_arn__} + inner_name = match.group(1) # e.g., lambda_function_arn + + # Check explicit map first (using the full token as key) + if full_token in placeholder_map: + return placeholder_map[full_token] + + # Auto-generate a dummy value based on the placeholder name pattern + return _generate_dummy_value(inner_name) + + return PLACEHOLDER_PATTERN.sub(_replace_match, asl_content) + + +def load_placeholder_map(path: str | Path) -> dict[str, str]: + """Load a placeholder map from a JSON file. + + The JSON file should contain a flat object mapping placeholder tokens + to their replacement values, e.g.: + { + "${__lambda_function_arn__}": "arn:aws:lambda:...:function:my-fn", + "${__event_bus_name__}": "my-event-bus" + } + + Args: + path: Path to the JSON file containing the placeholder map. + + Returns: + Dictionary mapping placeholder tokens to replacement values. + + Raises: + FileNotFoundError: If the JSON file does not exist. + json.JSONDecodeError: If the file is not valid JSON. + """ + file_path = Path(path) + with file_path.open("r") as f: + data = json.load(f) + + if not isinstance(data, dict): + raise ValueError( + f"Placeholder map file must contain a JSON object, got {type(data).__name__}" + ) + + return data + + +def _generate_dummy_value(inner_name: str) -> str: + """Generate a dummy replacement value based on the placeholder name pattern. + + Recognises common patterns in OrcaBus ASL templates: + - *_lambda_function_arn -> Lambda ARN + - *_state_machine_arn -> State Machine ARN + - *_event_bus_name / event_bus_name -> EventBridge bus name + - *_ssm_parameter_name / *_ssm_parameter_path_prefix -> SSM path + - *_detail_type -> EventBridge detail type string + - *_status -> Status string + - *_version -> Version string + - stack_source -> Event source string + - workflow_name -> Workflow name string + + Args: + inner_name: The name extracted from between ${__...__} delimiters. + + Returns: + A valid dummy value appropriate for the placeholder type. + """ + # Lambda function ARN + if inner_name.endswith("_lambda_function_arn"): + # Strip the _lambda_function_arn suffix to get the function name + fn_name = inner_name.removesuffix("_lambda_function_arn") + return ( + f"arn:aws:lambda:{DEFAULT_REGION}:{DEFAULT_ACCOUNT_ID}" + f":function:{fn_name}" + ) + + # State machine ARN + if inner_name.endswith("_state_machine_arn"): + sm_name = inner_name.removesuffix("_state_machine_arn") + return ( + f"arn:aws:states:{DEFAULT_REGION}:{DEFAULT_ACCOUNT_ID}" + f":stateMachine:{sm_name}" + ) + + # EventBridge event bus + if inner_name == "event_bus_name" or inner_name.endswith("_event_bus_name"): + bus_name = inner_name.removesuffix("_event_bus_name") or "default" + return ( + f"arn:aws:events:{DEFAULT_REGION}:{DEFAULT_ACCOUNT_ID}" + f":event-bus/{bus_name}" + ) + + # SSM parameter path prefix (both _ssm_parameter_path_prefix and _ssm_parameter_prefix) + if inner_name.endswith("_ssm_parameter_path_prefix"): + param_name = inner_name.removesuffix("_ssm_parameter_path_prefix") + return f"/test/{param_name.replace('_', '/')}" + + if inner_name.endswith("_ssm_parameter_prefix"): + param_name = inner_name.removesuffix("_ssm_parameter_prefix") + return f"/test/{param_name.replace('_', '/')}" + + # SSM parameter name + if inner_name.endswith("_ssm_parameter_name"): + param_name = inner_name.removesuffix("_ssm_parameter_name") + return f"/test/{param_name.replace('_', '/')}" + + # EventBridge detail type + if inner_name.endswith("_detail_type"): + detail_name = inner_name.removesuffix("_detail_type") + return detail_name.replace("_", ".") + + # Status strings (e.g., draft_status, ready_event_status, draft_event_status) + if inner_name.endswith("_status"): + status_name = inner_name.removesuffix("_status") + # Take only the last meaningful word for the status value + # e.g., "draft_event" -> "DRAFT", "ready_event" -> "READY" + parts = status_name.split("_") + # Use the first part as the status (typically "draft" or "ready") + return parts[0].upper() + + # Version strings + if inner_name.endswith("_version") or inner_name.startswith("default_payload_version"): + return "1.0.0" + + # S3 bucket names + if inner_name.endswith("_bucket"): + bucket_name = inner_name.removesuffix("_bucket") + return f"test-{bucket_name.replace('_', '-')}-bucket" + + # URI values (e.g., pipeline_cache_uri) + if inner_name.endswith("_uri"): + path_name = inner_name.removesuffix("_uri") + return f"s3://test-bucket/{path_name.replace('_', '-')}/" + + # Stack source + if inner_name == "stack_source": + return "orcabus.test" + + # Workflow name + if inner_name == "workflow_name": + return "test-workflow" + + # Generic fallback — return the inner name as a plain string value + # This ensures no placeholder is left unresolved + return inner_name.replace("_", "-") diff --git a/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/asl_validation/reference_checker.py b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/asl_validation/reference_checker.py new file mode 100644 index 0000000..f66386e --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/asl_validation/reference_checker.py @@ -0,0 +1,241 @@ +"""State and Lambda ARN reference checker. + +Validates that: +- All state references (Next, Default, Choice targets) resolve to defined states +- All Lambda ARN placeholders correspond to CDK-defined functions +""" + +from __future__ import annotations + +import json +import re +from typing import Any + +# Regex to match ${__xxx_lambda_function_arn__} placeholders +LAMBDA_ARN_PLACEHOLDER_PATTERN = re.compile( + r"\$\{__([a-z0-9_]+_lambda_function_arn)__\}" +) + + +def check_state_references(asl_json: dict) -> list[str]: + """Verify all 'Next', 'Default', and Choice branch targets reference existing states. + + Walks the entire ASL definition and checks that every state reference + (via "Next" fields, "Default" fields in Choice states, and Choice rule + branch targets) points to a state that exists in the top-level "States" map. + + Also verifies the "StartAt" field references an existing state. + + Args: + asl_json: Parsed ASL JSON definition as a dictionary. Expected to + contain "States" (dict) and optionally "StartAt" (str) at the + top level. + + Returns: + List of error strings describing dangling references. Empty list + means all references are valid. + """ + errors: list[str] = [] + + states = asl_json.get("States", {}) + if not isinstance(states, dict): + errors.append("'States' field is not a valid object") + return errors + + defined_state_names = set(states.keys()) + + # Check StartAt + start_at = asl_json.get("StartAt") + if isinstance(start_at, str) and start_at not in defined_state_names: + errors.append( + f"'StartAt' references undefined state '{start_at}'" + ) + + # Walk each state and collect reference errors + for state_name, state_def in states.items(): + if not isinstance(state_def, dict): + continue + + state_errors = _check_state_def_references( + state_name, state_def, defined_state_names + ) + errors.extend(state_errors) + + return errors + + +def _check_state_def_references( + state_name: str, + state_def: dict, + defined_state_names: set[str], +) -> list[str]: + """Check references within a single state definition. + + Args: + state_name: Name of the state being checked (for error messages). + state_def: The state definition dictionary. + defined_state_names: Set of all valid state names. + + Returns: + List of error strings for this state. + """ + errors: list[str] = [] + + # Check "Next" field (present in Task, Pass, Wait, Parallel, Map states) + next_state = state_def.get("Next") + if isinstance(next_state, str) and next_state not in defined_state_names: + errors.append( + f"State '{state_name}' has 'Next' referencing undefined state '{next_state}'" + ) + + # Check "Default" field (present in Choice states) + default_state = state_def.get("Default") + if isinstance(default_state, str) and default_state not in defined_state_names: + errors.append( + f"State '{state_name}' has 'Default' referencing undefined state '{default_state}'" + ) + + # Check Choice state rules (Choices array with "Next" in each rule) + choices = state_def.get("Choices") + if isinstance(choices, list): + for i, rule in enumerate(choices): + if not isinstance(rule, dict): + continue + rule_next = rule.get("Next") + if isinstance(rule_next, str) and rule_next not in defined_state_names: + errors.append( + f"State '{state_name}' Choice rule [{i}] has 'Next' " + f"referencing undefined state '{rule_next}'" + ) + + # Check Catch blocks (can reference states via "Next") + catch_blocks = state_def.get("Catch") + if isinstance(catch_blocks, list): + for i, catch_block in enumerate(catch_blocks): + if not isinstance(catch_block, dict): + continue + catch_next = catch_block.get("Next") + if isinstance(catch_next, str) and catch_next not in defined_state_names: + errors.append( + f"State '{state_name}' Catch [{i}] has 'Next' " + f"referencing undefined state '{catch_next}'" + ) + + # Check nested states in Parallel branches + branches = state_def.get("Branches") + if isinstance(branches, list): + for i, branch in enumerate(branches): + if not isinstance(branch, dict): + continue + # Each branch has its own States map — validate internally + branch_errors = _check_branch_references( + state_name, i, branch + ) + errors.extend(branch_errors) + + # Check Map state iterator/item processor + iterator = state_def.get("Iterator") or state_def.get("ItemProcessor") + if isinstance(iterator, dict): + branch_errors = _check_branch_references( + state_name, "Iterator", iterator + ) + errors.extend(branch_errors) + + return errors + + +def _check_branch_references( + parent_state_name: str, + branch_index: int | str, + branch_def: dict, +) -> list[str]: + """Check references within a Parallel branch or Map iterator. + + Branches have their own "States" map, so references are validated + against that local scope. + + Args: + parent_state_name: Name of the parent Parallel/Map state. + branch_index: Index or label of the branch. + branch_def: The branch definition with its own "States" and "StartAt". + + Returns: + List of error strings for this branch. + """ + errors: list[str] = [] + branch_states = branch_def.get("States", {}) + + if not isinstance(branch_states, dict): + return errors + + branch_state_names = set(branch_states.keys()) + + # Check branch StartAt + start_at = branch_def.get("StartAt") + if isinstance(start_at, str) and start_at not in branch_state_names: + errors.append( + f"State '{parent_state_name}' branch [{branch_index}] 'StartAt' " + f"references undefined state '{start_at}'" + ) + + # Recursively check each state in the branch + for state_name, state_def in branch_states.items(): + if not isinstance(state_def, dict): + continue + state_errors = _check_state_def_references( + f"{parent_state_name}.branch[{branch_index}].{state_name}", + state_def, + branch_state_names, + ) + errors.extend(state_errors) + + return errors + + +def check_lambda_arn_references( + asl_json: dict, + cdk_lambda_config: dict[str, Any], +) -> list[str]: + """Verify Lambda ARN placeholders correspond to CDK-defined functions. + + Scans the ASL definition for all ${__xxx_lambda_function_arn__} placeholders + and checks that each one has a corresponding entry in the CDK lambda + configuration map. + + Args: + asl_json: Parsed ASL definition as a dictionary. + cdk_lambda_config: CDK lambda configuration map with structure: + { + "lambdas": { + "": { + "placeholder": "${___lambda_function_arn__}", + "entry": "app/lambdas/_py" + } + } + } + + Returns: + List of error strings for unresolved placeholders. Empty if all valid. + """ + # Extract all known placeholders from the CDK config + known_placeholders: set[str] = set() + lambdas_config = cdk_lambda_config.get("lambdas", {}) + for lambda_name, lambda_info in lambdas_config.items(): + if isinstance(lambda_info, dict) and "placeholder" in lambda_info: + known_placeholders.add(lambda_info["placeholder"]) + + # Find all Lambda ARN placeholders in the ASL definition + asl_str = json.dumps(asl_json) + found_placeholders = set(LAMBDA_ARN_PLACEHOLDER_PATTERN.findall(asl_str)) + + # Check each found placeholder against the known set + errors: list[str] = [] + for inner_name in sorted(found_placeholders): + full_placeholder = f"${{__{inner_name}__}}" + if full_placeholder not in known_placeholders: + errors.append( + f"Lambda ARN placeholder '{full_placeholder}' in ASL definition " + f"has no corresponding entry in the CDK lambda configuration" + ) + + return errors diff --git a/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/asl_validation/validator.py b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/asl_validation/validator.py new file mode 100644 index 0000000..62d652b --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/asl_validation/validator.py @@ -0,0 +1,353 @@ +"""ASL structural validator. + +Validates ASL (Amazon States Language) JSON definitions for: +- Required fields (StartAt, States) +- Valid state types (Task, Pass, Choice, Wait, Succeed, Fail, Parallel, Map) +- Valid transitions (Next fields reference existing states) +- Structural integrity (StartAt references an existing state) +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum + + +class ValidationCategory(Enum): + """Category of validation result.""" + + SUCCESS = "SUCCESS" + SYNTAX_ERROR = "SYNTAX_ERROR" + REFERENCE_ERROR = "REFERENCE_ERROR" + + +@dataclass +class ValidationResult: + """Result of validating an ASL definition. + + Attributes: + file_path: Path to the ASL file being validated. + category: The validation outcome category. + errors: List of error messages (empty for SUCCESS). + warnings: List of warning messages. + """ + + file_path: str + category: ValidationCategory + errors: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + +# Valid ASL state types per the Amazon States Language specification +VALID_STATE_TYPES = frozenset( + {"Task", "Pass", "Choice", "Wait", "Succeed", "Fail", "Parallel", "Map"} +) + + +def validate_asl_definition( + asl_json: dict, + file_path: str = "", +) -> ValidationResult: + """Validate an ASL JSON structure for correctness. + + Checks: + 1. Required top-level fields: "StartAt" and "States" + 2. "States" is a non-empty dict + 3. "StartAt" references an existing state + 4. Each state has a valid "Type" field + 5. Terminal states (Succeed, Fail) do not have "Next" + 6. Non-terminal states (except Choice) have valid transitions + 7. State transitions ("Next" fields) reference existing states + 8. Choice state branch targets reference existing states + + Args: + asl_json: Parsed ASL JSON as a dictionary. + file_path: Path to the source file (for reporting). + + Returns: + ValidationResult with category, errors, and warnings. + """ + errors: list[str] = [] + warnings: list[str] = [] + reference_errors: list[str] = [] + + # --- Syntax checks --- + + # Check required top-level fields + if "StartAt" not in asl_json: + errors.append("Missing required field: 'StartAt'") + + if "States" not in asl_json: + errors.append("Missing required field: 'States'") + # Cannot proceed without States + return ValidationResult( + file_path=file_path, + category=ValidationCategory.SYNTAX_ERROR, + errors=errors, + warnings=warnings, + ) + + states = asl_json["States"] + + # States must be a dict + if not isinstance(states, dict): + errors.append( + f"'States' must be an object, got {type(states).__name__}" + ) + return ValidationResult( + file_path=file_path, + category=ValidationCategory.SYNTAX_ERROR, + errors=errors, + warnings=warnings, + ) + + # States must not be empty + if len(states) == 0: + errors.append("'States' must contain at least one state") + + # Check StartAt references an existing state + start_at = asl_json.get("StartAt") + if start_at is not None and start_at not in states: + reference_errors.append( + f"'StartAt' references non-existent state: '{start_at}'" + ) + + # Validate each state + state_names = set(states.keys()) + for state_name, state_def in states.items(): + if not isinstance(state_def, dict): + errors.append( + f"State '{state_name}' must be an object, " + f"got {type(state_def).__name__}" + ) + continue + + # Check Type field exists and is valid + if "Type" not in state_def: + errors.append(f"State '{state_name}' is missing required field: 'Type'") + continue + + state_type = state_def["Type"] + if state_type not in VALID_STATE_TYPES: + errors.append( + f"State '{state_name}' has invalid type: '{state_type}'. " + f"Valid types are: {sorted(VALID_STATE_TYPES)}" + ) + continue + + # Validate transitions based on state type + _validate_state_transitions( + state_name=state_name, + state_def=state_def, + state_type=state_type, + state_names=state_names, + reference_errors=reference_errors, + warnings=warnings, + ) + + # Determine final category + if errors: + return ValidationResult( + file_path=file_path, + category=ValidationCategory.SYNTAX_ERROR, + errors=errors, + warnings=warnings, + ) + + if reference_errors: + return ValidationResult( + file_path=file_path, + category=ValidationCategory.REFERENCE_ERROR, + errors=reference_errors, + warnings=warnings, + ) + + return ValidationResult( + file_path=file_path, + category=ValidationCategory.SUCCESS, + errors=[], + warnings=warnings, + ) + + +def _validate_state_transitions( + state_name: str, + state_def: dict, + state_type: str, + state_names: set[str], + reference_errors: list[str], + warnings: list[str], +) -> None: + """Validate transitions for a single state. + + Args: + state_name: Name of the state being validated. + state_def: The state definition dict. + state_type: The state's Type value. + state_names: Set of all valid state names in this machine. + reference_errors: List to append reference errors to. + warnings: List to append warnings to. + """ + is_terminal = state_type in ("Succeed", "Fail") + is_end = state_def.get("End", False) + + # Terminal states (Succeed, Fail) should not have Next + if is_terminal and "Next" in state_def: + warnings.append( + f"State '{state_name}' (type '{state_type}') has a 'Next' field " + f"which is ignored for terminal states" + ) + + # Check "Next" field references + if "Next" in state_def and not is_terminal: + next_state = state_def["Next"] + if isinstance(next_state, str) and next_state not in state_names: + reference_errors.append( + f"State '{state_name}' has 'Next' referencing " + f"non-existent state: '{next_state}'" + ) + + # Choice states: validate branch targets and Default + if state_type == "Choice": + _validate_choice_state( + state_name=state_name, + state_def=state_def, + state_names=state_names, + reference_errors=reference_errors, + ) + + # Non-terminal, non-Choice states should have either Next or End + if ( + not is_terminal + and state_type != "Choice" + and "Next" not in state_def + and not is_end + ): + warnings.append( + f"State '{state_name}' (type '{state_type}') has neither " + f"'Next' nor 'End: true' — this may be intentional if it's " + f"the last state" + ) + + # Parallel state: validate branch definitions + if state_type == "Parallel" and "Branches" in state_def: + _validate_parallel_branches( + state_name=state_name, + state_def=state_def, + reference_errors=reference_errors, + warnings=warnings, + ) + + # Map state: validate iterator + if state_type == "Map" and "ItemProcessor" in state_def: + _validate_map_iterator( + state_name=state_name, + state_def=state_def, + reference_errors=reference_errors, + warnings=warnings, + ) + + +def _validate_choice_state( + state_name: str, + state_def: dict, + state_names: set[str], + reference_errors: list[str], +) -> None: + """Validate Choice state branch targets and Default. + + Args: + state_name: Name of the Choice state. + state_def: The Choice state definition. + state_names: Set of all valid state names. + reference_errors: List to append reference errors to. + """ + # Check Choices array + choices = state_def.get("Choices", []) + if isinstance(choices, list): + for i, choice_rule in enumerate(choices): + if isinstance(choice_rule, dict) and "Next" in choice_rule: + target = choice_rule["Next"] + if isinstance(target, str) and target not in state_names: + reference_errors.append( + f"State '{state_name}' Choice rule [{i}] " + f"has 'Next' referencing non-existent state: '{target}'" + ) + + # Check Default field + default_state = state_def.get("Default") + if isinstance(default_state, str) and default_state not in state_names: + reference_errors.append( + f"State '{state_name}' has 'Default' referencing " + f"non-existent state: '{default_state}'" + ) + + +def _validate_parallel_branches( + state_name: str, + state_def: dict, + reference_errors: list[str], + warnings: list[str], +) -> None: + """Validate Parallel state branch definitions. + + Each branch is itself a mini state machine with StartAt and States. + + Args: + state_name: Name of the Parallel state. + state_def: The Parallel state definition. + reference_errors: List to append reference errors to. + warnings: List to append warnings to. + """ + branches = state_def.get("Branches", []) + if not isinstance(branches, list): + return + + for i, branch in enumerate(branches): + if not isinstance(branch, dict): + continue + + branch_start = branch.get("StartAt") + branch_states = branch.get("States", {}) + + if not isinstance(branch_states, dict): + continue + + if branch_start is not None and branch_start not in branch_states: + reference_errors.append( + f"State '{state_name}' Parallel branch [{i}] " + f"'StartAt' references non-existent state: '{branch_start}'" + ) + + +def _validate_map_iterator( + state_name: str, + state_def: dict, + reference_errors: list[str], + warnings: list[str], +) -> None: + """Validate Map state iterator/ItemProcessor definitions. + + The ItemProcessor contains a mini state machine. + + Args: + state_name: Name of the Map state. + state_def: The Map state definition. + reference_errors: List to append reference errors to. + warnings: List to append warnings to. + """ + item_processor = state_def.get("ItemProcessor", {}) + if not isinstance(item_processor, dict): + return + + proc_start = item_processor.get("StartAt") + proc_states = item_processor.get("States", {}) + + if not isinstance(proc_states, dict): + return + + if proc_start is not None and proc_start not in proc_states: + reference_errors.append( + f"State '{state_name}' Map ItemProcessor " + f"'StartAt' references non-existent state: '{proc_start}'" + ) diff --git a/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/assertions.py b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/assertions.py new file mode 100644 index 0000000..27ac016 --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/assertions.py @@ -0,0 +1,121 @@ +"""Shared assertion utilities. + +Provides reusable assertion primitives for all test harnesses: +- AssertionResult: Pass/fail dataclass with error accumulation +- match_json: Deep JSON comparison with wildcard support +- ANY_STRING_PLACEHOLDER: Wildcard marker for string-typed values +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field + + +# Wildcard placeholder indicating "any string value" +ANY_STRING_PLACEHOLDER = "{% any_string %}" + + +@dataclass +class AssertionError_: + """A single assertion failure with context.""" + + message: str + + +@dataclass +class AssertionResult: + """Result of running one or more assertions. + + Accumulates errors and tracks pass/fail state. Used by all assertion + classes across the package. + """ + + passed: bool + errors: list[AssertionError_] = field(default_factory=list) + + def add_error(self, message: str) -> None: + """Add an error and mark the result as failed.""" + self.passed = False + self.errors.append(AssertionError_(message=message)) + + +def match_json( + actual: object, + expected: object, + path: str = "$", +) -> list[str]: + """Compare actual JSON against expected, respecting wildcard placeholders. + + The ``{% any_string %}`` placeholder in the expected structure indicates + that the actual value must exist and be a string, but its exact value is + not checked. + + Args: + actual: The actual JSON value (parsed from execution output). + expected: The expected JSON structure (may contain wildcards). + path: JSONPath-style breadcrumb for error messages. + + Returns: + A list of mismatch descriptions. Empty list means match. + """ + errors: list[str] = [] + + if expected == ANY_STRING_PLACEHOLDER: + # Wildcard: actual must be a string + if not isinstance(actual, str): + errors.append( + f"{path}: expected a string (wildcard), " + f"got {type(actual).__name__}: {actual!r}" + ) + return errors + + if isinstance(expected, dict): + if not isinstance(actual, dict): + errors.append( + f"{path}: expected an object, got {type(actual).__name__}" + ) + return errors + + # Check all expected keys are present with correct values + for key in expected: + if key not in actual: + errors.append(f"{path}.{key}: missing in actual output") + else: + errors.extend( + match_json(actual[key], expected[key], path=f"{path}.{key}") + ) + + # Check for unexpected keys in actual + for key in actual: + if key not in expected: + errors.append(f"{path}.{key}: unexpected key in actual output") + + return errors + + if isinstance(expected, list): + if not isinstance(actual, list): + errors.append( + f"{path}: expected an array, got {type(actual).__name__}" + ) + return errors + + if len(actual) != len(expected): + errors.append( + f"{path}: array length mismatch: " + f"actual={len(actual)}, expected={len(expected)}" + ) + return errors + + for i, (a_item, e_item) in enumerate(zip(actual, expected)): + errors.extend(match_json(a_item, e_item, path=f"{path}[{i}]")) + + return errors + + # Scalar comparison + if actual != expected: + errors.append( + f"{path}: value mismatch: actual={actual!r}, expected={expected!r}" + ) + + return errors diff --git a/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/conftest_plugin.py b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/conftest_plugin.py new file mode 100644 index 0000000..aa40d56 --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/conftest_plugin.py @@ -0,0 +1,32 @@ +"""Pytest plugin for OrcaBus Pipeline Test Utilities. + +This module is auto-registered via the pytest11 entry point in pyproject.toml: + + [project.entry-points.pytest11] + orcabus_pipeline_test_utils = "orcabus_pipeline_test_utils.conftest_plugin" + +It exposes shared fixtures (AWS mocks, Lambda context, event builder) to all +tests in services that install this package — no explicit conftest imports needed. + +When pytest discovers this plugin at startup, it imports all fixture functions +below, making them available to any test session automatically. +""" + +# AWS mock fixtures (session-scoped, moto-based) +from orcabus_pipeline_test_utils.fixtures.aws_mocks import ( # noqa: F401 + _aws_credentials, + mock_events_client, + mock_s3_client, + mock_secretsmanager_client, + mock_ssm_client, +) + +# Lambda context fixture +from orcabus_pipeline_test_utils.fixtures.lambda_context import ( # noqa: F401 + lambda_context, +) + +# Event builder fixture +from orcabus_pipeline_test_utils.fixtures.event_builder import ( # noqa: F401 + event_builder, +) diff --git a/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/fixtures/__init__.py b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/fixtures/__init__.py new file mode 100644 index 0000000..502e183 --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/fixtures/__init__.py @@ -0,0 +1,17 @@ +"""Shared pytest fixtures for OrcaBus Pipeline Orchestrator tests. + +Provides: +- aws_mocks: Session-scoped moto-based AWS client fixtures +- lambda_context: Mock Lambda context fixture +- event_builder: Event builder fixture for handler invocation +""" + +from orcabus_pipeline_test_utils.fixtures.lambda_context import ( + MockLambdaContext, + lambda_context, +) + +__all__ = [ + "MockLambdaContext", + "lambda_context", +] diff --git a/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/fixtures/aws_mocks.py b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/fixtures/aws_mocks.py new file mode 100644 index 0000000..c3ef88b --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/fixtures/aws_mocks.py @@ -0,0 +1,99 @@ +"""Moto-based AWS mock fixtures. + +Provides session-scoped pre-configured boto3 clients for: +- S3 +- SSM Parameter Store +- Secrets Manager +- EventBridge + +These fixtures use moto's `mock_aws` context manager (moto v5+ unified API) +to provide fully mocked AWS services for testing without hitting real AWS. +The session scope ensures mocks persist across all tests in a session, +improving test performance. +""" + +import os + +import boto3 +import pytest +from moto import mock_aws + +# Default AWS region matching OrcaBus deployment +AWS_DEFAULT_REGION = "ap-southeast-2" + + +@pytest.fixture(scope="session", autouse=True) +def _aws_credentials(): + """Set dummy AWS credentials for moto. + + Moto requires AWS credentials to be set in the environment, + even though they are never used against real AWS services. + This fixture sets them for the entire test session. + """ + os.environ["AWS_ACCESS_KEY_ID"] = "testing" + os.environ["AWS_SECRET_ACCESS_KEY"] = "testing" # noqa: S105 # pragma: allowlist secret + os.environ["AWS_SECURITY_TOKEN"] = "testing" # noqa: S105 # pragma: allowlist secret + os.environ["AWS_SESSION_TOKEN"] = "testing" # noqa: S105 # pragma: allowlist secret + os.environ["AWS_DEFAULT_REGION"] = AWS_DEFAULT_REGION + + +@pytest.fixture(scope="session") +def mock_s3_client(): + """Provide a session-scoped mocked S3 boto3 client. + + Yields a boto3 S3 client operating against moto's in-memory + S3 service. The mock is active for the entire test session. + + Yields: + boto3.client: A mocked S3 client for the ap-southeast-2 region. + """ + with mock_aws(): + client = boto3.client("s3", region_name=AWS_DEFAULT_REGION) + yield client + + +@pytest.fixture(scope="session") +def mock_ssm_client(): + """Provide a session-scoped mocked SSM Parameter Store boto3 client. + + Yields a boto3 SSM client operating against moto's in-memory + SSM service. The mock is active for the entire test session. + + Yields: + boto3.client: A mocked SSM client for the ap-southeast-2 region. + """ + with mock_aws(): + client = boto3.client("ssm", region_name=AWS_DEFAULT_REGION) + yield client + + +@pytest.fixture(scope="session") +def mock_secretsmanager_client(): + """Provide a session-scoped mocked Secrets Manager boto3 client. + + Yields a boto3 Secrets Manager client operating against moto's + in-memory Secrets Manager service. The mock is active for the + entire test session. + + Yields: + boto3.client: A mocked Secrets Manager client for the ap-southeast-2 region. + """ + with mock_aws(): + client = boto3.client("secretsmanager", region_name=AWS_DEFAULT_REGION) + yield client + + +@pytest.fixture(scope="session") +def mock_events_client(): + """Provide a session-scoped mocked EventBridge boto3 client. + + Yields a boto3 EventBridge (events) client operating against moto's + in-memory EventBridge service. The mock is active for the entire + test session. + + Yields: + boto3.client: A mocked EventBridge client for the ap-southeast-2 region. + """ + with mock_aws(): + client = boto3.client("events", region_name=AWS_DEFAULT_REGION) + yield client diff --git a/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/fixtures/event_builder.py b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/fixtures/event_builder.py new file mode 100644 index 0000000..ab55fb1 --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/fixtures/event_builder.py @@ -0,0 +1,35 @@ +"""Event builder fixture. + +Provides an event_builder fixture that accepts a dictionary payload and returns +it as the event argument for Lambda handler invocation. + +Usage in tests:: + + def test_my_handler(event_builder, lambda_context): + event = event_builder({"key": "value", "data": {"nested": True}}) + result = handler(event, lambda_context) + assert result["statusCode"] == 200 +""" + +from typing import Any, Callable + +import pytest + + +@pytest.fixture +def event_builder() -> Callable[[dict[str, Any]], dict[str, Any]]: + """Return a factory function that builds Lambda event payloads. + + The factory accepts a dictionary payload and returns it directly as the + event argument suitable for passing to a Lambda handler. This provides a + clean abstraction point for future enhancements such as adding default + wrapper structures or EventBridge envelope formatting. + + Returns: + A callable that accepts a dict and returns it as the event payload. + """ + + def _build_event(payload: dict[str, Any]) -> dict[str, Any]: + return payload + + return _build_event diff --git a/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/fixtures/lambda_context.py b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/fixtures/lambda_context.py new file mode 100644 index 0000000..9a22a01 --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/fixtures/lambda_context.py @@ -0,0 +1,96 @@ +"""Mock Lambda context fixture. + +Provides a lambda_context fixture returning a mock AWS Lambda context object +with configurable function_name, memory_limit_in_mb, and aws_request_id. +""" + +import uuid +from dataclasses import dataclass, field +from typing import Callable + +import pytest + + +@dataclass +class MockLambdaContext: + """Mock AWS Lambda context object. + + Mimics the attributes available on the real Lambda context object + passed to handler functions at runtime. + """ + + function_name: str = "test-function" + memory_limit_in_mb: int = 128 + aws_request_id: str = field(default_factory=lambda: str(uuid.uuid4())) + function_version: str = "$LATEST" + invoked_function_arn: str = "" + log_group_name: str = "" + log_stream_name: str = "" + + def __post_init__(self) -> None: + """Set derived attributes based on function_name if not explicitly provided.""" + if not self.invoked_function_arn: + self.invoked_function_arn = ( + f"arn:aws:lambda:ap-southeast-2:123456789012:function:{self.function_name}" + ) + if not self.log_group_name: + self.log_group_name = f"/aws/lambda/{self.function_name}" + if not self.log_stream_name: + self.log_stream_name = ( + f"2024/01/01/[$LATEST]{self.aws_request_id.replace('-', '')}" + ) + + def get_remaining_time_in_millis(self) -> int: + """Return remaining execution time in milliseconds. + + Returns a fixed value for testing purposes. + """ + return 300000 # 5 minutes + + +@pytest.fixture +def lambda_context() -> Callable[..., MockLambdaContext]: + """Factory fixture for creating mock Lambda context objects. + + Returns a factory function that creates MockLambdaContext instances. + Call with no arguments for sensible defaults, or pass keyword arguments + to override specific attributes. + + Usage: + def test_handler(lambda_context): + # With defaults + ctx = lambda_context() + + # With custom values + ctx = lambda_context( + function_name="my-function", + memory_limit_in_mb=256, + aws_request_id="550e8400-e29b-41d4-a716-446655440000", + ) + + result = handler(event, ctx) + """ + + def _factory( + function_name: str = "test-function", + memory_limit_in_mb: int = 128, + aws_request_id: str | None = None, + function_version: str = "$LATEST", + invoked_function_arn: str = "", + log_group_name: str = "", + log_stream_name: str = "", + ) -> MockLambdaContext: + if aws_request_id is None: + aws_request_id = str(uuid.uuid4()) + + return MockLambdaContext( + function_name=function_name, + memory_limit_in_mb=memory_limit_in_mb, + aws_request_id=aws_request_id, + function_version=function_version, + invoked_function_arn=invoked_function_arn, + log_group_name=log_group_name, + log_stream_name=log_stream_name, + ) + + return _factory diff --git a/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/sfn_teststate/__init__.py b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/sfn_teststate/__init__.py new file mode 100644 index 0000000..c413c10 --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/sfn_teststate/__init__.py @@ -0,0 +1,28 @@ +"""Step Functions TestState API test harness. + +Provides state-level testing of Step Functions definitions using the AWS +TestState API. Supports JSONata query language, mocked service integrations, +and input/output data flow assertions. + +Modules: +- client: Wrapper around boto3's test_state API +- assertions: Result assertion utilities for TestState responses +""" + +from orcabus_pipeline_test_utils.sfn_teststate.client import ( + TestStateClient, + TestStateClientError, + TestStateResult, +) +from orcabus_pipeline_test_utils.sfn_teststate.assertions import ( + PathAssertion, + TestStateAssertion, +) + +__all__ = [ + "PathAssertion", + "TestStateAssertion", + "TestStateClient", + "TestStateClientError", + "TestStateResult", +] diff --git a/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/sfn_teststate/assertions.py b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/sfn_teststate/assertions.py new file mode 100644 index 0000000..2110640 --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/sfn_teststate/assertions.py @@ -0,0 +1,406 @@ +"""TestState API result assertions. + +Provides TestStateAssertion class for verifying TestState API responses: +- Status assertions (SUCCEEDED, FAILED, CAUGHT_ERROR, RETRIABLE) +- Output JSON matching with wildcard support +- Next state verification +- Data flow inspection (afterInputPath, afterParameters, etc.) +- Error handling assertions (catch index, retry backoff) +- Path execution assertions (states visited, terminal state) +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any + +from orcabus_pipeline_test_utils.assertions import ( + ANY_STRING_PLACEHOLDER, + AssertionResult, + match_json, +) +from orcabus_pipeline_test_utils.sfn_teststate.client import TestStateResult + + +class TestStateAssertion: + """Assertion helper for TestState API results. + + Provides methods to verify: + - State execution status + - Output JSON structure with wildcard support + - Next state transitions + - Input/output data processing at each stage + - Error handler activation (catch/retry) + - Execution path traversal + """ + + def __init__(self, result: TestStateResult) -> None: + """Initialise with a TestState result. + + Args: + result: The TestStateResult from TestStateClient.test_state(). + """ + self._result = result + + @property + def result(self) -> TestStateResult: + """Return the underlying TestStateResult.""" + return self._result + + def assert_status(self, expected_status: str) -> AssertionResult: + """Assert the state execution reached the expected status. + + Args: + expected_status: Expected status (SUCCEEDED, FAILED, CAUGHT_ERROR, + RETRIABLE). + + Returns: + AssertionResult indicating pass/fail. + """ + result = AssertionResult(passed=True) + if self._result.status != expected_status: + result.add_error( + f"Status mismatch: expected={expected_status!r}, " + f"actual={self._result.status!r}" + ) + return result + + def assert_succeeded(self) -> AssertionResult: + """Assert the state execution succeeded.""" + return self.assert_status("SUCCEEDED") + + def assert_failed(self) -> AssertionResult: + """Assert the state execution failed.""" + return self.assert_status("FAILED") + + def assert_caught_error(self) -> AssertionResult: + """Assert the error was caught by a Catch handler.""" + return self.assert_status("CAUGHT_ERROR") + + def assert_retriable(self) -> AssertionResult: + """Assert the error is retriable.""" + return self.assert_status("RETRIABLE") + + def assert_next_state(self, expected_next: str) -> AssertionResult: + """Assert the next state transition. + + Args: + expected_next: The expected next state name. + + Returns: + AssertionResult indicating pass/fail. + """ + result = AssertionResult(passed=True) + if self._result.next_state != expected_next: + result.add_error( + f"Next state mismatch: expected={expected_next!r}, " + f"actual={self._result.next_state!r}" + ) + return result + + def assert_output(self, expected_output: Any) -> AssertionResult: + """Assert the state output matches the expected structure. + + Supports {% any_string %} wildcard placeholder. + + Args: + expected_output: The expected output structure. + + Returns: + AssertionResult indicating pass/fail with mismatch details. + """ + result = AssertionResult(passed=True) + + if self._result.output is None: + result.add_error( + "State has no output (status may be FAILED or RETRIABLE). " + f"Status: {self._result.status}" + ) + return result + + errors = match_json(self._result.output, expected_output) + for error in errors: + result.add_error(error) + + return result + + def assert_error( + self, + expected_error: str | None = None, + expected_cause: str | None = None, + ) -> AssertionResult: + """Assert the error name and/or cause. + + Args: + expected_error: Expected error name (e.g., "Lambda.ServiceException"). + expected_cause: Expected error cause string. + + Returns: + AssertionResult indicating pass/fail. + """ + result = AssertionResult(passed=True) + + if expected_error is not None: + if self._result.error != expected_error: + result.add_error( + f"Error mismatch: expected={expected_error!r}, " + f"actual={self._result.error!r}" + ) + + if expected_cause is not None: + if self._result.cause != expected_cause: + result.add_error( + f"Cause mismatch: expected={expected_cause!r}, " + f"actual={self._result.cause!r}" + ) + + return result + + def assert_data_flow( + self, + *, + after_input_path: Any | None = None, + after_parameters: Any | None = None, + after_result_selector: Any | None = None, + after_result_path: Any | None = None, + ) -> AssertionResult: + """Assert intermediate data processing results. + + Requires inspection_level=DEBUG. Each parameter is optional; + only provided values are checked. + + Args: + after_input_path: Expected value after InputPath is applied. + after_parameters: Expected value after Parameters/Arguments is applied. + after_result_selector: Expected value after ResultSelector is applied. + after_result_path: Expected value after ResultPath is applied. + + Returns: + AssertionResult indicating pass/fail. + """ + result = AssertionResult(passed=True) + inspection = self._result.inspection_data + + if not inspection: + result.add_error( + "No inspectionData available. Use inspection_level='DEBUG'." + ) + return result + + checks = [ + ("afterInputPath", after_input_path), + ("afterParameters", after_parameters), + ("afterResultSelector", after_result_selector), + ("afterResultPath", after_result_path), + ] + + for field_name, expected in checks: + if expected is None: + continue + + raw_value = inspection.get(field_name) + if raw_value is None: + result.add_error( + f"inspectionData.{field_name} is not present in response" + ) + continue + + # Parse the raw JSON string + try: + actual = json.loads(raw_value) + except (json.JSONDecodeError, TypeError): + actual = raw_value + + errors = match_json(actual, expected) + for error in errors: + result.add_error(f"inspectionData.{field_name}: {error}") + + return result + + def assert_catch_index(self, expected_index: int) -> AssertionResult: + """Assert which Catch handler caught the error. + + Args: + expected_index: The zero-based index of the expected Catch handler. + + Returns: + AssertionResult indicating pass/fail. + """ + result = AssertionResult(passed=True) + inspection = self._result.inspection_data + error_details = inspection.get("errorDetails", {}) + actual_index = error_details.get("catchIndex") + + if actual_index is None: + result.add_error( + "No catchIndex in inspectionData.errorDetails. " + "State may not have caught the error." + ) + elif actual_index != expected_index: + result.add_error( + f"Catch index mismatch: expected={expected_index}, " + f"actual={actual_index}" + ) + + return result + + def assert_retry_backoff( + self, + expected_seconds: float | None = None, + expected_retry_index: int | None = None, + ) -> AssertionResult: + """Assert retry backoff configuration. + + Args: + expected_seconds: Expected backoff interval in seconds. + expected_retry_index: Expected zero-based index of the Retry handler. + + Returns: + AssertionResult indicating pass/fail. + """ + result = AssertionResult(passed=True) + inspection = self._result.inspection_data + error_details = inspection.get("errorDetails", {}) + + if expected_seconds is not None: + actual = error_details.get("retryBackoffIntervalSeconds") + if actual is None: + result.add_error( + "No retryBackoffIntervalSeconds in errorDetails." + ) + elif actual != expected_seconds: + result.add_error( + f"Retry backoff mismatch: expected={expected_seconds}s, " + f"actual={actual}s" + ) + + if expected_retry_index is not None: + actual_idx = error_details.get("retryIndex") + if actual_idx is None: + result.add_error("No retryIndex in errorDetails.") + elif actual_idx != expected_retry_index: + result.add_error( + f"Retry index mismatch: expected={expected_retry_index}, " + f"actual={actual_idx}" + ) + + return result + + +class PathAssertion: + """Assertion helper for chained TestState path results. + + Provides methods to verify execution paths produced by + TestStateClient.test_path(). + """ + + def __init__(self, results: list[TestStateResult]) -> None: + """Initialise with a list of path results. + + Args: + results: Ordered list of TestStateResult from test_path(). + """ + self._results = results + + @property + def states_visited(self) -> list[str]: + """Return the list of state transitions from the execution path. + + Note: The TestState API does not echo the state name in its response. + This property returns the `next_state` values from each result + (excluding the last), representing the transitions observed during + the path execution. Use `assert_step_count` for verifying the + number of states actually executed. + """ + return [r.next_state for r in self._results[:-1] if r.next_state] if self._results else [] + + @property + def terminal_status(self) -> str: + """Return the status of the last state in the path.""" + if not self._results: + return "UNKNOWN" + return self._results[-1].status + + @property + def terminal_output(self) -> Any: + """Return the output of the last state in the path.""" + if not self._results: + return None + return self._results[-1].output + + def assert_path_succeeded(self) -> AssertionResult: + """Assert the entire path completed with the last state succeeding.""" + result = AssertionResult(passed=True) + if not self._results: + result.add_error("No results in path.") + return result + + last = self._results[-1] + if last.status != "SUCCEEDED": + result.add_error( + f"Path did not succeed. Last state status: {last.status}" + ) + # Terminal state should have no next_state (End state) + if last.next_state: + result.add_error( + f"Path did not reach terminal state. " + f"Last nextState: {last.next_state!r}" + ) + return result + + def assert_path_failed(self) -> AssertionResult: + """Assert the path ended in a failure.""" + result = AssertionResult(passed=True) + if not self._results: + result.add_error("No results in path.") + return result + + last = self._results[-1] + if last.status not in ("FAILED", "CAUGHT_ERROR"): + result.add_error( + f"Path did not fail. Last state status: {last.status}" + ) + return result + + def assert_step_count(self, expected_count: int) -> AssertionResult: + """Assert the number of states traversed. + + Args: + expected_count: Expected number of state executions. + + Returns: + AssertionResult indicating pass/fail. + """ + result = AssertionResult(passed=True) + actual = len(self._results) + if actual != expected_count: + result.add_error( + f"Step count mismatch: expected={expected_count}, actual={actual}" + ) + return result + + def assert_terminal_output(self, expected_output: Any) -> AssertionResult: + """Assert the final output of the path. + + Args: + expected_output: Expected output structure (supports wildcards). + + Returns: + AssertionResult indicating pass/fail. + """ + result = AssertionResult(passed=True) + if not self._results: + result.add_error("No results in path.") + return result + + last = self._results[-1] + if last.output is None: + result.add_error("Terminal state has no output.") + return result + + errors = match_json(last.output, expected_output) + for error in errors: + result.add_error(error) + + return result diff --git a/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/sfn_teststate/client.py b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/sfn_teststate/client.py new file mode 100644 index 0000000..13bdd62 --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/sfn_teststate/client.py @@ -0,0 +1,381 @@ +"""TestState API client wrapper. + +Wraps boto3's stepfunctions.test_state() API for testing individual states +in Step Functions definitions. Supports: +- Testing individual states with mocked service integrations +- Testing states within a full state machine definition (stateName parameter) +- Chaining state executions to simulate execution paths +- DEBUG/TRACE inspection levels for data flow verification +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass, field +from typing import Any + +import boto3 + +logger = logging.getLogger(__name__) + +_DEFAULT_REGION = "ap-southeast-2" + + +class TestStateClientError(Exception): + """Raised when the TestState API returns an error.""" + + +@dataclass +class TestStateResult: + """Parsed result from a TestState API call. + + Attributes: + status: The execution status (SUCCEEDED, FAILED, RETRIABLE, CAUGHT_ERROR). + output: The parsed JSON output from the state (None if failed without output). + next_state: The next state to transition to (empty string if End state). + error: The error name if the state failed. + cause: The error cause if the state failed. + inspection_data: The raw inspectionData dict (present with DEBUG/TRACE levels). + raw_response: The full API response dict. + """ + + status: str + output: Any = None + next_state: str = "" + error: str | None = None + cause: str | None = None + inspection_data: dict = field(default_factory=dict) + raw_response: dict = field(default_factory=dict) + + @property + def succeeded(self) -> bool: + """Return True if the state execution succeeded.""" + return self.status == "SUCCEEDED" + + @property + def failed(self) -> bool: + """Return True if the state execution failed.""" + return self.status == "FAILED" + + @property + def caught_error(self) -> bool: + """Return True if the error was caught by a Catch handler.""" + return self.status == "CAUGHT_ERROR" + + @property + def retriable(self) -> bool: + """Return True if the error is retriable.""" + return self.status == "RETRIABLE" + + +class TestStateClient: + """Wrapper around the Step Functions TestState API. + + Provides methods for testing individual states with mocked responses, + testing states within full definitions, and chaining state executions + to simulate execution paths. + """ + + def __init__( + self, + region_name: str = _DEFAULT_REGION, + role_arn: str | None = None, + session: boto3.Session | None = None, + client=None, + ) -> None: + """Initialise the TestState client. + + Args: + region_name: AWS region for the Step Functions service. + role_arn: Optional IAM role ARN. Not required when using mocks. + session: Optional boto3 session. Creates a new one if not provided. + client: Optional pre-built boto3 stepfunctions client. If provided, + session is ignored for client creation. + """ + self._region_name = region_name + self._role_arn = role_arn + self._session = session or boto3.Session(region_name=region_name) + self._client = client or self._session.client( + "stepfunctions", region_name=region_name + ) + + def test_state( + self, + definition: str | dict, + input_data: str | dict | None = None, + *, + state_name: str | None = None, + mock_result: str | dict | None = None, + mock_error: dict | None = None, + inspection_level: str = "DEBUG", + role_arn: str | None = None, + context: dict | None = None, + state_configuration: dict | None = None, + field_validation_mode: str | None = None, + variables: str | dict | None = None, + ) -> TestStateResult: + """Test a single state using the TestState API. + + Args: + definition: The state definition (single state) or full state machine + definition (when using state_name). Can be a dict or JSON string. + input_data: The input to the state. Can be a dict or JSON string. + Defaults to empty object "{}". + state_name: When definition is a full state machine, specifies which + state to test. If None, definition is treated as a single state. + mock_result: The mocked successful result from the service integration. + Mutually exclusive with mock_error. Can be a dict or JSON string. + mock_error: The mocked error response. Must contain "error" and "cause" + keys. Mutually exclusive with mock_result. + inspection_level: Level of detail in response: "INFO", "DEBUG", or "TRACE". + role_arn: Override the default role ARN for this call. + context: Optional Context object values ($$.Execution.Id, etc.). + state_configuration: Optional state configuration (e.g., retrierRetryCount). + field_validation_mode: Mock validation mode: "STRICT", "PRESENT", or "NONE". + + Returns: + TestStateResult with parsed response data. + + Raises: + TestStateClientError: If the API call fails. + """ + # Normalise definition to JSON string + if isinstance(definition, dict): + definition_str = json.dumps(definition) + else: + definition_str = definition + + # Normalise input to JSON string + if input_data is None: + input_str = "{}" + elif isinstance(input_data, dict): + input_str = json.dumps(input_data) + else: + input_str = input_data + + # Build the API kwargs + kwargs: dict[str, Any] = { + "definition": definition_str, + "input": input_str, + "inspectionLevel": inspection_level, + } + + # Add state_name if provided (testing within a full definition) + if state_name is not None: + kwargs["stateName"] = state_name + + # Add role ARN if available (not required with mocks) + effective_role = role_arn or self._role_arn + if effective_role is not None: + kwargs["roleArn"] = effective_role + + # Build mock parameter + if mock_result is not None and mock_error is not None: + raise TestStateClientError( + "Cannot provide both mock_result and mock_error. " + "Use one or the other." + ) + + if mock_result is not None: + mock_obj: dict[str, Any] = {} + if isinstance(mock_result, dict): + mock_obj["result"] = json.dumps(mock_result) + else: + mock_obj["result"] = mock_result + if field_validation_mode is not None: + mock_obj["fieldValidationMode"] = field_validation_mode + kwargs["mock"] = mock_obj + + if mock_error is not None: + mock_obj = {"errorOutput": mock_error} + if field_validation_mode is not None: + mock_obj["fieldValidationMode"] = field_validation_mode + kwargs["mock"] = mock_obj + + # Add context if provided + if context is not None: + kwargs["context"] = json.dumps(context) + # Add variables if provided (for testing states that reference assigned variables) + if variables is not None: + if isinstance(variables, dict): + kwargs["variables"] = json.dumps(variables) + else: + kwargs["variables"] = variables + + # Add state configuration if provided + if state_configuration is not None: + kwargs["stateConfiguration"] = json.dumps(state_configuration) + + # Call the API + logger.debug( + "TestState API call: state_name=%s, inspection_level=%s", + state_name, + inspection_level, + ) + try: + response = self._client.test_state(**kwargs) + except self._client.exceptions.InvalidDefinition as e: + raise TestStateClientError( + f"Invalid state definition: {e}" + ) from e + except self._client.exceptions.InvalidExecutionInput as e: + raise TestStateClientError( + f"Invalid execution input: {e}" + ) from e + except Exception as e: + raise TestStateClientError( + f"TestState API call failed: {e}" + ) from e + + # Parse the response + return self._parse_response(response) + + def test_path( + self, + definition: str | dict, + input_data: str | dict, + state_mocks: dict[str, dict], + *, + start_state: str | None = None, + max_steps: int = 50, + inspection_level: str = "DEBUG", + role_arn: str | None = None, + ) -> list[TestStateResult]: + """Chain TestState calls to simulate an execution path. + + Iterates through states by feeding the output of one state as input + to the next, using the nextState field to determine the path. Stops + when a terminal state (End=true, no nextState) is reached or max_steps + is exceeded. + + Args: + definition: The full state machine definition (dict or JSON string). + input_data: The initial input to the first state. + state_mocks: A dict mapping state names to their mock configuration. + Each value should have either "result" or "errorOutput" key. + Example: {"LaunchDragen": {"result": {"job_id": "123"}}} + start_state: The state to start from. If None, uses StartAt. + max_steps: Maximum number of states to traverse. Default 50. + inspection_level: Inspection level for each call. + role_arn: Override role ARN for all calls. + + Returns: + Ordered list of TestStateResult for each state visited. + + Raises: + TestStateClientError: If any state test fails unexpectedly. + """ + if isinstance(definition, str): + definition_dict = json.loads(definition) + else: + definition_dict = definition + + # Determine starting state + current_state = start_state or definition_dict.get("StartAt") + if current_state is None: + raise TestStateClientError( + "No start state specified and definition has no StartAt field." + ) + + # Normalise input + if isinstance(input_data, dict): + current_input = json.dumps(input_data) + else: + current_input = input_data + + results: list[TestStateResult] = [] + + for step in range(max_steps): + # Get mock for current state + mock_config = state_mocks.get(current_state, {}) + mock_result = mock_config.get("result") + mock_error = mock_config.get("errorOutput") + + # Build mock kwargs + mock_kwargs: dict[str, Any] = {} + if mock_result is not None: + mock_kwargs["mock_result"] = mock_result + if mock_error is not None: + mock_kwargs["mock_error"] = mock_error + + # Execute the state + result = self.test_state( + definition=definition_dict, + input_data=current_input, + state_name=current_state, + inspection_level=inspection_level, + role_arn=role_arn, + **mock_kwargs, + ) + results.append(result) + + logger.info( + "Step %d: state=%s, status=%s, next=%s", + step + 1, + current_state, + result.status, + result.next_state, + ) + + # Stop on terminal states or errors + if result.status in ("FAILED", "RETRIABLE"): + break + + # If caught error, follow the catch handler's next state + if result.caught_error: + if result.next_state: + current_state = result.next_state + current_input = ( + json.dumps(result.output) + if result.output is not None + else "{}" + ) + continue + else: + break + + # Check if this is an End state (no next_state) + if not result.next_state: + break + + # Move to next state + current_state = result.next_state + current_input = ( + json.dumps(result.output) + if result.output is not None + else "{}" + ) + + return results + + @staticmethod + def _parse_response(response: dict) -> TestStateResult: + """Parse a raw TestState API response into a TestStateResult. + + Args: + response: The raw boto3 response dict. + + Returns: + Parsed TestStateResult. + """ + status = response.get("status", "UNKNOWN") + + # Parse output JSON + raw_output = response.get("output") + output = None + if raw_output is not None: + try: + output = json.loads(raw_output) + except (json.JSONDecodeError, TypeError): + output = raw_output + + return TestStateResult( + status=status, + output=output, + next_state=response.get("nextState", ""), + error=response.get("error"), + cause=response.get("cause"), + inspection_data=response.get("inspectionData", {}), + raw_response=response, + ) diff --git a/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/smoke/__init__.py b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/smoke/__init__.py new file mode 100644 index 0000000..53030b4 --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/smoke/__init__.py @@ -0,0 +1,20 @@ +"""Post-deployment smoke test utilities. + +Provides: +- lambda_check: Lambda DryRun invocation checker +- sfn_check: DescribeStateMachine checker +- ssm_check: SSM parameter existence checker +""" + +from dataclasses import dataclass + + +@dataclass +class SmokeTestResult: + """Result of a single smoke test check against a deployed AWS resource.""" + + resource_name: str + resource_type: str # "lambda" | "state_machine" | "ssm_parameter" + passed: bool + error_type: str | None # "auth" | "config" | None + error_message: str | None diff --git a/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/smoke/lambda_check.py b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/smoke/lambda_check.py new file mode 100644 index 0000000..f6a93a9 --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/smoke/lambda_check.py @@ -0,0 +1,85 @@ +"""Lambda DryRun invocation checker. + +Performs InvocationType: DryRun invocation via boto3 and returns +SmokeTestResult with passed=True on HTTP 204, passed=False otherwise. +Classifies AccessDeniedException/ExpiredTokenException as error_type="auth", +all other failures as error_type="config". +""" + +from __future__ import annotations + +import boto3 +from botocore.exceptions import ClientError + +from orcabus_pipeline_test_utils.smoke import SmokeTestResult + +# Exceptions classified as authentication/authorization errors +_AUTH_ERROR_CODES = {"AccessDeniedException", "ExpiredTokenException"} + + +def check_lambda_invocable( + function_name: str, + session: boto3.Session, +) -> SmokeTestResult: + """Perform a DryRun invocation of a Lambda function and verify HTTP 204. + + A DryRun invocation validates that the caller has permission to invoke + the function and that the function configuration is valid, without + actually executing the function code. + + Args: + function_name: The name or ARN of the Lambda function to check. + session: A boto3 Session configured with appropriate credentials. + + Returns: + SmokeTestResult with passed=True if HTTP 204 is returned, + passed=False with error classification otherwise. + """ + client = session.client("lambda") + + try: + response = client.invoke( + FunctionName=function_name, + InvocationType="DryRun", + ) + except ClientError as e: + error_code = e.response["Error"]["Code"] + error_message = e.response["Error"]["Message"] + + error_type = "auth" if error_code in _AUTH_ERROR_CODES else "config" + + return SmokeTestResult( + resource_name=function_name, + resource_type="lambda", + passed=False, + error_type=error_type, + error_message=f"{error_code}: {error_message}", + ) + except Exception as e: + # Catch non-ClientError exceptions (network timeouts, etc.) + return SmokeTestResult( + resource_name=function_name, + resource_type="lambda", + passed=False, + error_type="config", + error_message=str(e), + ) + + status_code = response.get("StatusCode", 0) + + if status_code == 204: + return SmokeTestResult( + resource_name=function_name, + resource_type="lambda", + passed=True, + error_type=None, + error_message=None, + ) + + return SmokeTestResult( + resource_name=function_name, + resource_type="lambda", + passed=False, + error_type="config", + error_message=f"Expected HTTP 204, got {status_code}", + ) diff --git a/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/smoke/sfn_check.py b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/smoke/sfn_check.py new file mode 100644 index 0000000..2e28db1 --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/smoke/sfn_check.py @@ -0,0 +1,83 @@ +"""State Machine checker. + +Calls DescribeStateMachine and verifies status=ACTIVE with non-null definition. +Returns SmokeTestResult with appropriate error classification. +""" + +from __future__ import annotations + +import boto3 +from botocore.exceptions import ClientError + +from orcabus_pipeline_test_utils.smoke import SmokeTestResult + +# Auth-related error codes that indicate IAM/credential issues +_AUTH_ERROR_CODES = {"AccessDeniedException", "ExpiredTokenException"} + + +def check_state_machine_active( + state_machine_arn: str, + session: boto3.Session, +) -> SmokeTestResult: + """Call DescribeStateMachine, verify status=ACTIVE and definition non-null. + + Args: + state_machine_arn: The full ARN of the Step Functions state machine. + session: A boto3 Session configured for the target environment. + + Returns: + SmokeTestResult with passed=True if the state machine is ACTIVE with + a non-null definition, or passed=False with error details otherwise. + """ + client = session.client("stepfunctions") + + try: + response = client.describe_state_machine(stateMachineArn=state_machine_arn) + except ClientError as exc: + error_code = exc.response["Error"]["Code"] + error_message = exc.response["Error"]["Message"] + error_type = "auth" if error_code in _AUTH_ERROR_CODES else "config" + return SmokeTestResult( + resource_name=state_machine_arn, + resource_type="state_machine", + passed=False, + error_type=error_type, + error_message=f"{error_code}: {error_message}", + ) + except Exception as exc: + return SmokeTestResult( + resource_name=state_machine_arn, + resource_type="state_machine", + passed=False, + error_type="config", + error_message=str(exc), + ) + + status = response.get("status") + definition = response.get("definition") + + if status != "ACTIVE": + return SmokeTestResult( + resource_name=state_machine_arn, + resource_type="state_machine", + passed=False, + error_type="config", + error_message=f"State machine status is '{status}', expected 'ACTIVE'", + ) + + if not definition: + return SmokeTestResult( + resource_name=state_machine_arn, + resource_type="state_machine", + passed=False, + error_type="config", + error_message="State machine definition is null or empty", + ) + + return SmokeTestResult( + resource_name=state_machine_arn, + resource_type="state_machine", + passed=True, + error_type=None, + error_message=None, + ) diff --git a/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/smoke/ssm_check.py b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/smoke/ssm_check.py new file mode 100644 index 0000000..159f018 --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/src/orcabus_pipeline_test_utils/smoke/ssm_check.py @@ -0,0 +1,78 @@ +"""SSM parameter existence checker. + +Verifies each SSM parameter exists and is readable via GetParameter. +Returns list of SmokeTestResult with error classification per parameter. +""" + +from __future__ import annotations + +import boto3 +from botocore.exceptions import ClientError + +from orcabus_pipeline_test_utils.smoke import SmokeTestResult + +# Exception codes classified as authentication/authorization errors +_AUTH_ERROR_CODES = {"AccessDeniedException", "ExpiredTokenException"} + + +def check_ssm_parameters_exist( + parameter_paths: list[str], + session: boto3.Session, +) -> list[SmokeTestResult]: + """Verify each SSM parameter exists and is readable. + + Calls ssm:GetParameter for each path and returns a SmokeTestResult per parameter. + + Error classification: + - AccessDeniedException / ExpiredTokenException → error_type="auth" + - All other failures → error_type="config" + + Args: + parameter_paths: List of SSM parameter paths to verify. + session: A boto3 Session configured for the target environment. + + Returns: + A list of SmokeTestResult, one per parameter path. + """ + client = session.client("ssm") + results: list[SmokeTestResult] = [] + + for path in parameter_paths: + try: + client.get_parameter(Name=path, WithDecryption=True) + results.append( + SmokeTestResult( + resource_name=path, + resource_type="ssm_parameter", + passed=True, + error_type=None, + error_message=None, + ) + ) + except ClientError as exc: + error_code = exc.response["Error"]["Code"] + error_message = exc.response["Error"].get("Message", str(exc)) + error_type = "auth" if error_code in _AUTH_ERROR_CODES else "config" + + results.append( + SmokeTestResult( + resource_name=path, + resource_type="ssm_parameter", + passed=False, + error_type=error_type, + error_message=error_message, + ) + ) + except Exception as exc: + # Catch non-ClientError exceptions (network timeouts, etc.) + results.append( + SmokeTestResult( + resource_name=path, + resource_type="ssm_parameter", + passed=False, + error_type="config", + error_message=str(exc), + ) + ) + + return results diff --git a/packages/orcabus-pipeline-test-utils/tests/__init__.py b/packages/orcabus-pipeline-test-utils/tests/__init__.py new file mode 100644 index 0000000..82668b0 --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for orcabus-pipeline-test-utils package.""" diff --git a/packages/orcabus-pipeline-test-utils/tests/test_asl_validator.py b/packages/orcabus-pipeline-test-utils/tests/test_asl_validator.py new file mode 100644 index 0000000..1d20d78 --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/tests/test_asl_validator.py @@ -0,0 +1,421 @@ +"""Unit tests for the ASL structural validator.""" + +import pytest + +from orcabus_pipeline_test_utils.asl_validation.validator import ( + VALID_STATE_TYPES, + ValidationCategory, + ValidationResult, + validate_asl_definition, +) + + +class TestValidateAslDefinition: + """Tests for validate_asl_definition.""" + + def test_valid_simple_state_machine(self): + """A minimal valid state machine returns SUCCESS.""" + asl = { + "StartAt": "MyTask", + "States": { + "MyTask": { + "Type": "Task", + "Resource": "arn:aws:lambda:us-east-1:123456789012:function:my-fn", + "End": True, + } + }, + } + result = validate_asl_definition(asl, file_path="test.asl.json") + + assert result.category == ValidationCategory.SUCCESS + assert result.file_path == "test.asl.json" + assert result.errors == [] + + def test_valid_multi_state_machine(self): + """A multi-state machine with valid transitions returns SUCCESS.""" + asl = { + "StartAt": "First", + "States": { + "First": {"Type": "Pass", "Next": "Second"}, + "Second": {"Type": "Task", "Resource": "arn:aws:...", "Next": "Third"}, + "Third": {"Type": "Succeed"}, + }, + } + result = validate_asl_definition(asl) + + assert result.category == ValidationCategory.SUCCESS + assert result.errors == [] + + def test_missing_start_at(self): + """Missing StartAt returns SYNTAX_ERROR.""" + asl = { + "States": { + "MyTask": {"Type": "Task", "Resource": "...", "End": True} + } + } + result = validate_asl_definition(asl) + + assert result.category == ValidationCategory.SYNTAX_ERROR + assert any("StartAt" in e for e in result.errors) + + def test_missing_states(self): + """Missing States returns SYNTAX_ERROR.""" + asl = {"StartAt": "MyTask"} + result = validate_asl_definition(asl) + + assert result.category == ValidationCategory.SYNTAX_ERROR + assert any("States" in e for e in result.errors) + + def test_states_not_a_dict(self): + """States that is not a dict returns SYNTAX_ERROR.""" + asl = {"StartAt": "MyTask", "States": ["not", "a", "dict"]} + result = validate_asl_definition(asl) + + assert result.category == ValidationCategory.SYNTAX_ERROR + assert any("object" in e for e in result.errors) + + def test_empty_states(self): + """Empty States dict returns SYNTAX_ERROR.""" + asl = {"StartAt": "MyTask", "States": {}} + result = validate_asl_definition(asl) + + assert result.category == ValidationCategory.SYNTAX_ERROR + assert any("at least one state" in e for e in result.errors) + + def test_invalid_state_type(self): + """Invalid state type returns SYNTAX_ERROR.""" + asl = { + "StartAt": "Bad", + "States": { + "Bad": {"Type": "InvalidType", "End": True} + }, + } + result = validate_asl_definition(asl) + + assert result.category == ValidationCategory.SYNTAX_ERROR + assert any("invalid type" in e.lower() for e in result.errors) + + def test_missing_type_field(self): + """State missing Type field returns SYNTAX_ERROR.""" + asl = { + "StartAt": "NoType", + "States": { + "NoType": {"Resource": "...", "End": True} + }, + } + result = validate_asl_definition(asl) + + assert result.category == ValidationCategory.SYNTAX_ERROR + assert any("Type" in e for e in result.errors) + + def test_start_at_references_nonexistent_state(self): + """StartAt referencing a missing state returns REFERENCE_ERROR.""" + asl = { + "StartAt": "NonExistent", + "States": { + "ActualState": {"Type": "Succeed"} + }, + } + result = validate_asl_definition(asl) + + assert result.category == ValidationCategory.REFERENCE_ERROR + assert any("NonExistent" in e for e in result.errors) + + def test_next_references_nonexistent_state(self): + """Next referencing a missing state returns REFERENCE_ERROR.""" + asl = { + "StartAt": "First", + "States": { + "First": {"Type": "Pass", "Next": "DoesNotExist"}, + }, + } + result = validate_asl_definition(asl) + + assert result.category == ValidationCategory.REFERENCE_ERROR + assert any("DoesNotExist" in e for e in result.errors) + + def test_choice_branch_references_nonexistent_state(self): + """Choice branch targeting a missing state returns REFERENCE_ERROR.""" + asl = { + "StartAt": "ChoiceState", + "States": { + "ChoiceState": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.foo", + "StringEquals": "bar", + "Next": "MissingState", + } + ], + "Default": "FallbackState", + }, + "FallbackState": {"Type": "Succeed"}, + }, + } + result = validate_asl_definition(asl) + + assert result.category == ValidationCategory.REFERENCE_ERROR + assert any("MissingState" in e for e in result.errors) + + def test_choice_default_references_nonexistent_state(self): + """Choice Default targeting a missing state returns REFERENCE_ERROR.""" + asl = { + "StartAt": "ChoiceState", + "States": { + "ChoiceState": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.foo", + "StringEquals": "bar", + "Next": "BranchState", + } + ], + "Default": "MissingDefault", + }, + "BranchState": {"Type": "Succeed"}, + }, + } + result = validate_asl_definition(asl) + + assert result.category == ValidationCategory.REFERENCE_ERROR + assert any("MissingDefault" in e for e in result.errors) + + def test_valid_choice_state(self): + """A valid Choice state with proper targets returns SUCCESS.""" + asl = { + "StartAt": "ChoiceState", + "States": { + "ChoiceState": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.type", + "StringEquals": "A", + "Next": "HandleA", + }, + { + "Variable": "$.type", + "StringEquals": "B", + "Next": "HandleB", + }, + ], + "Default": "HandleDefault", + }, + "HandleA": {"Type": "Succeed"}, + "HandleB": {"Type": "Succeed"}, + "HandleDefault": {"Type": "Fail", "Error": "UnknownType"}, + }, + } + result = validate_asl_definition(asl) + + assert result.category == ValidationCategory.SUCCESS + assert result.errors == [] + + def test_valid_parallel_state(self): + """A valid Parallel state returns SUCCESS.""" + asl = { + "StartAt": "ParallelState", + "States": { + "ParallelState": { + "Type": "Parallel", + "Branches": [ + { + "StartAt": "BranchA", + "States": { + "BranchA": {"Type": "Pass", "End": True} + }, + }, + { + "StartAt": "BranchB", + "States": { + "BranchB": {"Type": "Succeed"} + }, + }, + ], + "End": True, + } + }, + } + result = validate_asl_definition(asl) + + assert result.category == ValidationCategory.SUCCESS + + def test_parallel_branch_invalid_start_at(self): + """Parallel branch with invalid StartAt returns REFERENCE_ERROR.""" + asl = { + "StartAt": "ParallelState", + "States": { + "ParallelState": { + "Type": "Parallel", + "Branches": [ + { + "StartAt": "NonExistentBranch", + "States": { + "ActualBranch": {"Type": "Pass", "End": True} + }, + } + ], + "End": True, + } + }, + } + result = validate_asl_definition(asl) + + assert result.category == ValidationCategory.REFERENCE_ERROR + assert any("NonExistentBranch" in e for e in result.errors) + + def test_valid_map_state(self): + """A valid Map state with ItemProcessor returns SUCCESS.""" + asl = { + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "ItemProcessor": { + "StartAt": "ProcessItem", + "States": { + "ProcessItem": {"Type": "Task", "Resource": "...", "End": True} + }, + }, + "End": True, + } + }, + } + result = validate_asl_definition(asl) + + assert result.category == ValidationCategory.SUCCESS + + def test_map_item_processor_invalid_start_at(self): + """Map ItemProcessor with invalid StartAt returns REFERENCE_ERROR.""" + asl = { + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "ItemProcessor": { + "StartAt": "NonExistentProcessor", + "States": { + "ActualProcessor": {"Type": "Pass", "End": True} + }, + }, + "End": True, + } + }, + } + result = validate_asl_definition(asl) + + assert result.category == ValidationCategory.REFERENCE_ERROR + assert any("NonExistentProcessor" in e for e in result.errors) + + def test_all_valid_state_types(self): + """All valid state types are accepted without error.""" + for state_type in VALID_STATE_TYPES: + state_def: dict = {"Type": state_type} + if state_type not in ("Succeed", "Fail", "Choice"): + state_def["End"] = True + if state_type == "Task": + state_def["Resource"] = "arn:aws:..." + if state_type == "Choice": + state_def["Choices"] = [ + {"Variable": "$.x", "StringEquals": "y", "Next": "End"} + ] + state_def["Default"] = "End" + + # Build a machine with this state and an End state + states = {"TestState": state_def} + if state_type == "Choice": + states["End"] = {"Type": "Succeed"} + + asl = {"StartAt": "TestState", "States": states} + result = validate_asl_definition(asl) + + assert result.category == ValidationCategory.SUCCESS, ( + f"State type '{state_type}' unexpectedly failed: {result.errors}" + ) + + def test_file_path_included_in_result(self): + """The file_path is included in the result.""" + asl = { + "StartAt": "S", + "States": {"S": {"Type": "Succeed"}}, + } + result = validate_asl_definition(asl, file_path="/my/path.asl.json") + + assert result.file_path == "/my/path.asl.json" + + def test_default_file_path(self): + """Default file_path is ''.""" + asl = { + "StartAt": "S", + "States": {"S": {"Type": "Succeed"}}, + } + result = validate_asl_definition(asl) + + assert result.file_path == "" + + def test_syntax_error_takes_priority_over_reference_error(self): + """When both syntax and reference errors exist, SYNTAX_ERROR is returned.""" + asl = { + "StartAt": "NonExistent", + "States": { + "BadState": {"Type": "NotAValidType", "End": True} + }, + } + result = validate_asl_definition(asl) + + # Syntax error (invalid type) takes priority + assert result.category == ValidationCategory.SYNTAX_ERROR + + def test_state_def_not_a_dict(self): + """A state definition that is not a dict produces SYNTAX_ERROR.""" + asl = { + "StartAt": "BadDef", + "States": { + "BadDef": "not a dict" + }, + } + result = validate_asl_definition(asl) + + assert result.category == ValidationCategory.SYNTAX_ERROR + assert any("object" in e for e in result.errors) + + def test_wait_state_with_next(self): + """A Wait state with a valid Next is accepted.""" + asl = { + "StartAt": "WaitState", + "States": { + "WaitState": {"Type": "Wait", "Seconds": 10, "Next": "Done"}, + "Done": {"Type": "Succeed"}, + }, + } + result = validate_asl_definition(asl) + + assert result.category == ValidationCategory.SUCCESS + + def test_succeed_state_no_next_required(self): + """Succeed state doesn't need Next or End.""" + asl = { + "StartAt": "Done", + "States": { + "Done": {"Type": "Succeed"} + }, + } + result = validate_asl_definition(asl) + + assert result.category == ValidationCategory.SUCCESS + # No warnings about missing Next/End for terminal states + assert not any("neither" in w.lower() for w in result.warnings) + + def test_fail_state_no_next_required(self): + """Fail state doesn't need Next or End.""" + asl = { + "StartAt": "FailState", + "States": { + "FailState": {"Type": "Fail", "Error": "SomeError", "Cause": "Reason"} + }, + } + result = validate_asl_definition(asl) + + assert result.category == ValidationCategory.SUCCESS diff --git a/packages/orcabus-pipeline-test-utils/tests/test_aws_mocks.py b/packages/orcabus-pipeline-test-utils/tests/test_aws_mocks.py new file mode 100644 index 0000000..c28f53c --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/tests/test_aws_mocks.py @@ -0,0 +1,96 @@ +"""Tests for AWS mock fixtures. + +Verifies that the session-scoped moto fixtures provide working boto3 clients. +""" + +import pytest + + +# Import the fixtures so pytest can discover them +from orcabus_pipeline_test_utils.fixtures.aws_mocks import ( + _aws_credentials, + mock_s3_client, + mock_ssm_client, + mock_secretsmanager_client, + mock_events_client, +) + + +class TestMockS3Client: + """Tests for the mock_s3_client fixture.""" + + def test_create_bucket(self, mock_s3_client): + """Verify we can create and list buckets via the mocked S3 client.""" + mock_s3_client.create_bucket( + Bucket="test-bucket", + CreateBucketConfiguration={"LocationConstraint": "ap-southeast-2"}, + ) + response = mock_s3_client.list_buckets() + bucket_names = [b["Name"] for b in response["Buckets"]] + assert "test-bucket" in bucket_names + + def test_put_and_get_object(self, mock_s3_client): + """Verify we can put and get objects via the mocked S3 client.""" + mock_s3_client.create_bucket( + Bucket="data-bucket", + CreateBucketConfiguration={"LocationConstraint": "ap-southeast-2"}, + ) + mock_s3_client.put_object( + Bucket="data-bucket", + Key="test-key", + Body=b"test-content", + ) + response = mock_s3_client.get_object(Bucket="data-bucket", Key="test-key") + assert response["Body"].read() == b"test-content" + + +class TestMockSSMClient: + """Tests for the mock_ssm_client fixture.""" + + def test_put_and_get_parameter(self, mock_ssm_client): + """Verify we can put and get SSM parameters.""" + mock_ssm_client.put_parameter( + Name="/orcabus/test/param", + Value="test-value", + Type="String", + ) + response = mock_ssm_client.get_parameter(Name="/orcabus/test/param") + assert response["Parameter"]["Value"] == "test-value" + + +class TestMockSecretsManagerClient: + """Tests for the mock_secretsmanager_client fixture.""" + + def test_create_and_get_secret(self, mock_secretsmanager_client): + """Verify we can create and retrieve secrets.""" + mock_secretsmanager_client.create_secret( + Name="test-secret", + SecretString="super-secret-value", # pragma: allowlist secret + ) + response = mock_secretsmanager_client.get_secret_value(SecretId="test-secret") + assert response["SecretString"] == "super-secret-value" + + +class TestMockEventsClient: + """Tests for the mock_events_client fixture.""" + + def test_put_events(self, mock_events_client): + """Verify we can put events to EventBridge.""" + response = mock_events_client.put_events( + Entries=[ + { + "Source": "orcabus.test", + "DetailType": "TestEvent", + "Detail": '{"key": "value"}', + "EventBusName": "default", + } + ] + ) + assert response["FailedEntryCount"] == 0 + + def test_create_event_bus(self, mock_events_client): + """Verify we can create an event bus.""" + mock_events_client.create_event_bus(Name="OrcaBusMain") + response = mock_events_client.list_event_buses() + bus_names = [b["Name"] for b in response["EventBuses"]] + assert "OrcaBusMain" in bus_names diff --git a/packages/orcabus-pipeline-test-utils/tests/test_event_builder.py b/packages/orcabus-pipeline-test-utils/tests/test_event_builder.py new file mode 100644 index 0000000..5739ed8 --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/tests/test_event_builder.py @@ -0,0 +1,57 @@ +"""Tests for the event_builder fixture.""" + + +def test_event_builder_returns_callable(event_builder): + """The fixture should return a callable factory function.""" + assert callable(event_builder) + + +def test_event_builder_passthrough_simple_dict(event_builder): + """The factory should return the same dict that was passed in.""" + payload = {"key": "value"} + result = event_builder(payload) + assert result == {"key": "value"} + + +def test_event_builder_passthrough_nested_dict(event_builder): + """The factory should handle nested dictionaries.""" + payload = {"data": {"nested": True, "items": [1, 2, 3]}} + result = event_builder(payload) + assert result == {"data": {"nested": True, "items": [1, 2, 3]}} + + +def test_event_builder_passthrough_empty_dict(event_builder): + """The factory should handle an empty dictionary.""" + payload: dict = {} + result = event_builder(payload) + assert result == {} + + +def test_event_builder_returns_same_reference(event_builder): + """The factory should return the exact same dict object (not a copy).""" + payload = {"key": "value"} + result = event_builder(payload) + assert result is payload + + +def test_event_builder_complex_event_payload(event_builder): + """The factory should handle a realistic Lambda event payload.""" + payload = { + "version": "0", + "source": "orcabus.dragenwgtsdna", + "detail-type": "WorkflowRunStateChange", + "detail": { + "status": "DRAFT", + "workflowName": "dragen-wgts-dna", + "payload": { + "version": "2025.08.05", + "data": { + "libraryId": "LIB001", + "sampleId": "SMP001", + }, + }, + }, + } + result = event_builder(payload) + assert result == payload + assert result["detail"]["status"] == "DRAFT" diff --git a/packages/orcabus-pipeline-test-utils/tests/test_lambda_arn_checker.py b/packages/orcabus-pipeline-test-utils/tests/test_lambda_arn_checker.py new file mode 100644 index 0000000..1181a49 --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/tests/test_lambda_arn_checker.py @@ -0,0 +1,224 @@ +"""Unit tests for check_lambda_arn_references function.""" + +import pytest + +from orcabus_pipeline_test_utils.asl_validation.reference_checker import ( + check_lambda_arn_references, +) + + +def _make_asl_with_lambdas(placeholders: list[str]) -> dict: + """Helper to build an ASL definition containing Lambda ARN placeholders.""" + states = {} + for i, placeholder in enumerate(placeholders): + states[f"State{i}"] = { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Arguments": { + "FunctionName": placeholder, + "Payload": {"key": "value"}, + }, + "End": True if i == len(placeholders) - 1 else False, + "Next": f"State{i + 1}" if i < len(placeholders) - 1 else None, + } + return { + "StartAt": "State0", + "States": states, + } + + +def _make_cdk_config(lambdas: dict[str, str]) -> dict: + """Helper to build a CDK lambda config map. + + Args: + lambdas: Mapping of lambda_name -> placeholder string. + """ + return { + "lambdas": { + name: { + "placeholder": placeholder, + "entry": f"app/lambdas/{name}_py", + } + for name, placeholder in lambdas.items() + } + } + + +class TestCheckLambdaArnReferences: + """Tests for check_lambda_arn_references.""" + + def test_no_placeholders_returns_no_errors(self): + """ASL with no Lambda ARN placeholders produces no errors.""" + asl = { + "StartAt": "PassState", + "States": { + "PassState": { + "Type": "Pass", + "End": True, + } + }, + } + errors = check_lambda_arn_references(asl, {"lambdas": {}}) + assert errors == [] + + def test_all_placeholders_resolved(self): + """All Lambda ARN placeholders have CDK config entries.""" + asl = _make_asl_with_lambdas([ + "${__calculate_downsampling_ratios_lambda_function_arn__}", + "${__populate_draft_data_lambda_function_arn__}", + ]) + cdk_config = _make_cdk_config({ + "calculate_downsampling_ratios": "${__calculate_downsampling_ratios_lambda_function_arn__}", + "populate_draft_data": "${__populate_draft_data_lambda_function_arn__}", + }) + errors = check_lambda_arn_references(asl, cdk_config) + assert errors == [] + + def test_unresolved_placeholder_returns_error(self): + """A Lambda ARN placeholder not in CDK config produces an error.""" + asl = _make_asl_with_lambdas([ + "${__my_missing_lambda_function_arn__}", + ]) + cdk_config = _make_cdk_config({}) + errors = check_lambda_arn_references(asl, cdk_config) + assert len(errors) == 1 + assert "${__my_missing_lambda_function_arn__}" in errors[0] + + def test_partial_resolution(self): + """Some placeholders resolved, some not — errors only for unresolved.""" + asl = _make_asl_with_lambdas([ + "${__get_libraries_lambda_function_arn__}", + "${__unknown_lambda_function_arn__}", + "${__another_missing_lambda_function_arn__}", + ]) + cdk_config = _make_cdk_config({ + "get_libraries": "${__get_libraries_lambda_function_arn__}", + }) + errors = check_lambda_arn_references(asl, cdk_config) + assert len(errors) == 2 + # Errors should be sorted + assert "${__another_missing_lambda_function_arn__}" in errors[0] + assert "${__unknown_lambda_function_arn__}" in errors[1] + + def test_empty_asl_states(self): + """An ASL with empty States map produces no errors.""" + asl = {"StartAt": "Start", "States": {}} + cdk_config = _make_cdk_config({ + "some_function": "${__some_function_lambda_function_arn__}", + }) + errors = check_lambda_arn_references(asl, cdk_config) + assert errors == [] + + def test_empty_cdk_config(self): + """Empty CDK config with Lambda placeholders in ASL produces errors.""" + asl = _make_asl_with_lambdas([ + "${__get_workflow_run_object_lambda_function_arn__}", + ]) + cdk_config = {"lambdas": {}} + errors = check_lambda_arn_references(asl, cdk_config) + assert len(errors) == 1 + + def test_non_lambda_placeholders_ignored(self): + """Non-Lambda ARN placeholders (e.g., workflow_name) are not checked.""" + asl = { + "StartAt": "PassState", + "States": { + "PassState": { + "Type": "Pass", + "Result": { + "workflowName": "${__workflow_name__}", + "version": "${__default_payload_version__}", + }, + "End": True, + } + }, + } + cdk_config = {"lambdas": {}} + errors = check_lambda_arn_references(asl, cdk_config) + assert errors == [] + + def test_duplicate_placeholders_counted_once(self): + """Same placeholder used multiple times produces only one error.""" + asl = _make_asl_with_lambdas([ + "${__repeated_lambda_function_arn__}", + "${__repeated_lambda_function_arn__}", + ]) + cdk_config = _make_cdk_config({}) + errors = check_lambda_arn_references(asl, cdk_config) + assert len(errors) == 1 + + def test_missing_lambdas_key_in_config(self): + """CDK config without 'lambdas' key treats all placeholders as unresolved.""" + asl = _make_asl_with_lambdas([ + "${__my_lambda_function_arn__}", + ]) + cdk_config = {} # No "lambdas" key + errors = check_lambda_arn_references(asl, cdk_config) + assert len(errors) == 1 + + def test_nested_asl_structure(self): + """Lambda ARN placeholders in deeply nested structures are found.""" + asl = { + "StartAt": "Parallel", + "States": { + "Parallel": { + "Type": "Parallel", + "Branches": [ + { + "StartAt": "Branch1Task", + "States": { + "Branch1Task": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Arguments": { + "FunctionName": "${__branch1_lambda_function_arn__}", + }, + "End": True, + } + }, + }, + { + "StartAt": "Branch2Task", + "States": { + "Branch2Task": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Arguments": { + "FunctionName": "${__branch2_lambda_function_arn__}", + }, + "End": True, + } + }, + }, + ], + "End": True, + } + }, + } + cdk_config = _make_cdk_config({ + "branch1": "${__branch1_lambda_function_arn__}", + }) + errors = check_lambda_arn_references(asl, cdk_config) + assert len(errors) == 1 + assert "${__branch2_lambda_function_arn__}" in errors[0] + + def test_realistic_cdk_config_structure(self): + """Validates against the real CDK config format from the design doc.""" + asl = _make_asl_with_lambdas([ + "${__calculate_downsampling_ratios_lambda_function_arn__}", + "${__populate_draft_data_lambda_function_arn__}", + ]) + cdk_config = { + "lambdas": { + "calculate_downsampling_ratios": { + "placeholder": "${__calculate_downsampling_ratios_lambda_function_arn__}", + "entry": "app/lambdas/calculate_downsampling_ratios_py", + }, + "populate_draft_data": { + "placeholder": "${__populate_draft_data_lambda_function_arn__}", + "entry": "app/lambdas/populate_draft_data_py", + }, + } + } + errors = check_lambda_arn_references(asl, cdk_config) + assert errors == [] diff --git a/packages/orcabus-pipeline-test-utils/tests/test_lambda_context.py b/packages/orcabus-pipeline-test-utils/tests/test_lambda_context.py new file mode 100644 index 0000000..f05a066 --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/tests/test_lambda_context.py @@ -0,0 +1,100 @@ +"""Unit tests for the lambda_context fixture.""" + +import uuid + +from orcabus_pipeline_test_utils.fixtures.lambda_context import MockLambdaContext + + +class TestMockLambdaContext: + """Tests for MockLambdaContext dataclass.""" + + def test_default_values(self): + """MockLambdaContext should have sensible defaults.""" + ctx = MockLambdaContext() + assert ctx.function_name == "test-function" + assert ctx.memory_limit_in_mb == 128 + # aws_request_id should be a valid UUID + uuid.UUID(ctx.aws_request_id) + assert ctx.function_version == "$LATEST" + + def test_custom_values(self): + """MockLambdaContext should accept custom attribute values.""" + request_id = str(uuid.uuid4()) + ctx = MockLambdaContext( + function_name="my-function", + memory_limit_in_mb=256, + aws_request_id=request_id, + ) + assert ctx.function_name == "my-function" + assert ctx.memory_limit_in_mb == 256 + assert ctx.aws_request_id == request_id + + def test_derived_attributes(self): + """MockLambdaContext should derive ARN and log attributes from function_name.""" + ctx = MockLambdaContext(function_name="my-func") + assert ctx.invoked_function_arn == ( + "arn:aws:lambda:ap-southeast-2:123456789012:function:my-func" + ) + assert ctx.log_group_name == "/aws/lambda/my-func" + assert "$LATEST" in ctx.log_stream_name + + def test_explicit_arn_not_overridden(self): + """When invoked_function_arn is explicitly set, it should not be overridden.""" + custom_arn = "arn:aws:lambda:us-east-1:999999999999:function:custom" + ctx = MockLambdaContext( + function_name="my-func", + invoked_function_arn=custom_arn, + ) + assert ctx.invoked_function_arn == custom_arn + + def test_get_remaining_time_in_millis(self): + """get_remaining_time_in_millis should return a positive integer.""" + ctx = MockLambdaContext() + remaining = ctx.get_remaining_time_in_millis() + assert isinstance(remaining, int) + assert remaining > 0 + + +class TestLambdaContextFixture: + """Tests for the lambda_context factory fixture.""" + + def test_factory_returns_context_with_defaults(self, lambda_context): + """Factory with no args should return context with default values.""" + ctx = lambda_context() + assert ctx.function_name == "test-function" + assert ctx.memory_limit_in_mb == 128 + uuid.UUID(ctx.aws_request_id) # Should be valid UUID + + def test_factory_accepts_custom_function_name(self, lambda_context): + """Factory should allow overriding function_name.""" + ctx = lambda_context(function_name="custom-handler") + assert ctx.function_name == "custom-handler" + + def test_factory_accepts_custom_memory(self, lambda_context): + """Factory should allow overriding memory_limit_in_mb.""" + ctx = lambda_context(memory_limit_in_mb=512) + assert ctx.memory_limit_in_mb == 512 + + def test_factory_accepts_custom_request_id(self, lambda_context): + """Factory should allow overriding aws_request_id.""" + request_id = "550e8400-e29b-41d4-a716-446655440000" + ctx = lambda_context(aws_request_id=request_id) + assert ctx.aws_request_id == request_id + + def test_factory_generates_unique_request_ids(self, lambda_context): + """Each call without explicit request_id should generate a unique UUID.""" + ctx1 = lambda_context() + ctx2 = lambda_context() + assert ctx1.aws_request_id != ctx2.aws_request_id + + def test_factory_all_custom_values(self, lambda_context): + """Factory should accept all custom values simultaneously.""" + request_id = str(uuid.uuid4()) + ctx = lambda_context( + function_name="full-custom", + memory_limit_in_mb=1024, + aws_request_id=request_id, + ) + assert ctx.function_name == "full-custom" + assert ctx.memory_limit_in_mb == 1024 + assert ctx.aws_request_id == request_id diff --git a/packages/orcabus-pipeline-test-utils/tests/test_placeholder_resolver.py b/packages/orcabus-pipeline-test-utils/tests/test_placeholder_resolver.py new file mode 100644 index 0000000..86c5271 --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/tests/test_placeholder_resolver.py @@ -0,0 +1,251 @@ +"""Unit tests for the placeholder resolver module.""" + +import json +import tempfile +from pathlib import Path + +from orcabus_pipeline_test_utils.asl_validation.placeholder_resolver import ( + PLACEHOLDER_PATTERN, + load_placeholder_map, + resolve_placeholders, +) + + +class TestResolvePlaceholders: + """Tests for the resolve_placeholders function.""" + + def test_no_placeholders(self): + """Content without placeholders should be returned unchanged.""" + content = '{"StartAt": "MyState", "States": {}}' + result = resolve_placeholders(content) + assert result == content + + def test_lambda_function_arn_auto_generated(self): + """Lambda ARN placeholders should auto-generate valid ARNs.""" + content = '"FunctionName": "${__my_handler_lambda_function_arn__}"' + result = resolve_placeholders(content) + assert "arn:aws:lambda:ap-southeast-2:123456789012:function:my_handler" in result + assert "${__" not in result + + def test_state_machine_arn_auto_generated(self): + """State machine ARN placeholders should auto-generate valid ARNs.""" + content = '"StateMachineArn": "${__process_sfn_state_machine_arn__}"' + result = resolve_placeholders(content) + assert "arn:aws:states:ap-southeast-2:123456789012:stateMachine:process_sfn" in result + + def test_event_bus_name_auto_generated(self): + """Event bus name placeholders should auto-generate valid ARNs.""" + content = '"EventBusName": "${__event_bus_name__}"' + result = resolve_placeholders(content) + assert "arn:aws:events:ap-southeast-2:123456789012:event-bus/" in result + + def test_ssm_parameter_name_auto_generated(self): + """SSM parameter name placeholders should auto-generate paths.""" + content = '"Name": "${__default_project_id_ssm_parameter_name__}"' + result = resolve_placeholders(content) + assert "/test/" in result + assert "${__" not in result + + def test_ssm_parameter_path_prefix_auto_generated(self): + """SSM parameter path prefix placeholders should auto-generate paths.""" + content = '"Name": "${__workflow_id_to_pipeline_id_ssm_parameter_path_prefix__}"' + result = resolve_placeholders(content) + assert "/test/" in result + assert "${__" not in result + + def test_detail_type_auto_generated(self): + """Detail type placeholders should auto-generate dot-separated strings.""" + content = '"DetailType": "${__workflow_run_update_event_detail_type__}"' + result = resolve_placeholders(content) + assert "${__" not in result + # Should be a dot-separated string + assert "." in result + + def test_status_auto_generated(self): + """Status placeholders should auto-generate uppercase status strings.""" + content = '"status": "${__draft_status__}"' + result = resolve_placeholders(content) + assert result == '"status": "DRAFT"' + + def test_event_status_auto_generated(self): + """Event status placeholders should take the first meaningful word.""" + content = '"status": "${__ready_event_status__}"' + result = resolve_placeholders(content) + assert result == '"status": "READY"' + + def test_stack_source_auto_generated(self): + """Stack source placeholder should resolve to a dotted source name.""" + content = '"Source": "${__stack_source__}"' + result = resolve_placeholders(content) + assert result == '"Source": "orcabus.test"' + + def test_workflow_name_auto_generated(self): + """Workflow name placeholder should resolve to a workflow string.""" + content = '"workflowName": "${__workflow_name__}"' + result = resolve_placeholders(content) + assert result == '"workflowName": "test-workflow"' + + def test_default_payload_version_auto_generated(self): + """Payload version placeholder should resolve to a version string.""" + content = '"version": "${__default_payload_version__}"' + result = resolve_placeholders(content) + assert result == '"version": "1.0.0"' + + def test_explicit_map_overrides_auto_generation(self): + """An explicit placeholder map should override auto-generation.""" + content = '"FunctionName": "${__my_lambda_function_arn__}"' + explicit_map = { + "${__my_lambda_function_arn__}": "arn:aws:lambda:us-west-2:999999999999:function:custom" + } + result = resolve_placeholders(content, explicit_map) + assert "arn:aws:lambda:us-west-2:999999999999:function:custom" in result + + def test_partial_map_uses_auto_generation_for_unmapped(self): + """Unmapped placeholders should still be auto-generated when map is partial.""" + content = ( + '"FunctionName": "${__fn_a_lambda_function_arn__}", ' + '"Source": "${__stack_source__}"' + ) + partial_map = { + "${__fn_a_lambda_function_arn__}": "arn:aws:lambda:us-east-1:111:function:custom-a" + } + result = resolve_placeholders(content, partial_map) + assert "arn:aws:lambda:us-east-1:111:function:custom-a" in result + assert "orcabus.test" in result + + def test_multiple_placeholders_in_content(self): + """Multiple distinct placeholders should all be resolved.""" + content = json.dumps({ + "FunctionName": "${__handler_lambda_function_arn__}", + "EventBusName": "${__event_bus_name__}", + "Source": "${__stack_source__}", + }) + result = resolve_placeholders(content) + # No unresolved placeholders should remain + assert "${__" not in result + + def test_repeated_placeholder_resolved_consistently(self): + """The same placeholder appearing multiple times should resolve identically.""" + content = ( + '"First": "${__my_lambda_function_arn__}", ' + '"Second": "${__my_lambda_function_arn__}"' + ) + result = resolve_placeholders(content) + # Extract the two resolved values + parts = result.split('"First": "')[1].split('", "Second": "') + assert parts[0] == parts[1].rstrip('"') + + def test_empty_map_triggers_auto_generation(self): + """An empty dict should still trigger auto-generation (same as None).""" + content = '"FunctionName": "${__my_lambda_function_arn__}"' + result = resolve_placeholders(content, {}) + assert "arn:aws:lambda:" in result + + def test_generic_fallback_for_unknown_pattern(self): + """Unknown placeholder patterns should get a hyphenated fallback value.""" + content = '"Value": "${__some_unknown_thing__}"' + result = resolve_placeholders(content) + assert "${__" not in result + # Fallback converts underscores to hyphens + assert "some-unknown-thing" in result + + def test_real_world_asl_snippet(self): + """Test with a realistic ASL JSON snippet from OrcaBus services.""" + content = json.dumps({ + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Arguments": { + "FunctionName": "${__validate_draft_complete_schema_lambda_function_arn__}", + "Payload": {"data": "some-data"}, + }, + "Next": "Check Result", + }) + result = resolve_placeholders(content) + parsed = json.loads(result) + fn_name = parsed["Arguments"]["FunctionName"] + assert fn_name.startswith("arn:aws:lambda:") + assert "validate_draft_complete_schema" in fn_name + + +class TestLoadPlaceholderMap: + """Tests for the load_placeholder_map function.""" + + def test_load_valid_json_file(self): + """Should load a valid JSON placeholder map file.""" + map_data = { + "${__my_lambda_function_arn__}": "arn:aws:lambda:us-east-1:123:function:test", + "${__event_bus_name__}": "my-bus", + } + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(map_data, f) + f.flush() + result = load_placeholder_map(f.name) + + assert result == map_data + + def test_load_from_path_object(self): + """Should accept Path objects as input.""" + map_data = {"${__key__}": "value"} + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(map_data, f) + f.flush() + result = load_placeholder_map(Path(f.name)) + + assert result == map_data + + def test_file_not_found_raises(self): + """Should raise FileNotFoundError for missing files.""" + import pytest + + with pytest.raises(FileNotFoundError): + load_placeholder_map("/nonexistent/path/map.json") + + def test_invalid_json_raises(self): + """Should raise JSONDecodeError for invalid JSON.""" + import pytest + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + f.write("not valid json {{{") + f.flush() + + with pytest.raises(json.JSONDecodeError): + load_placeholder_map(f.name) + + def test_non_object_json_raises(self): + """Should raise ValueError if JSON root is not an object.""" + import pytest + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(["not", "an", "object"], f) + f.flush() + + with pytest.raises(ValueError, match="JSON object"): + load_placeholder_map(f.name) + + +class TestPlaceholderPattern: + """Tests for the PLACEHOLDER_PATTERN regex.""" + + def test_matches_standard_placeholder(self): + """Should match ${__name__} format.""" + match = PLACEHOLDER_PATTERN.search("${__my_placeholder__}") + assert match is not None + assert match.group(1) == "my_placeholder" + + def test_does_not_match_partial(self): + """Should not match incomplete placeholder syntax.""" + assert PLACEHOLDER_PATTERN.search("${my_placeholder}") is None + assert PLACEHOLDER_PATTERN.search("${__my_placeholder}") is None + assert PLACEHOLDER_PATTERN.search("__my_placeholder__") is None + + def test_matches_with_numbers(self): + """Should match placeholders containing numbers.""" + match = PLACEHOLDER_PATTERN.search("${__v2_handler_lambda_function_arn__}") + assert match is not None + assert match.group(1) == "v2_handler_lambda_function_arn" + + def test_findall_multiple(self): + """Should find all placeholders in a string.""" + content = '${__first__} middle ${__second__}' + matches = PLACEHOLDER_PATTERN.findall(content) + assert matches == ["first", "second"] diff --git a/packages/orcabus-pipeline-test-utils/tests/test_property_asl_validation.py b/packages/orcabus-pipeline-test-utils/tests/test_property_asl_validation.py new file mode 100644 index 0000000..653a8cd --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/tests/test_property_asl_validation.py @@ -0,0 +1,345 @@ +"""Property-based tests for ASL validation categorization. + +# Feature: deployment-integration-tests, Property 3: ASL Validation Categorization + +**Validates: Requirements 4.1, 4.2** + +For any Step Function ASL definition (valid or invalid), the `validate_asl_definition` +function SHALL return a ValidationResult containing the correct ValidationCategory +(SUCCESS for valid definitions, SYNTAX_ERROR for malformed JSON/invalid state types, +REFERENCE_ERROR for dangling references), the file path of the input, and non-empty +error details for non-SUCCESS categories. +""" + +from __future__ import annotations + +import string + +from hypothesis import given, settings, assume +from hypothesis import strategies as st + +from orcabus_pipeline_test_utils.asl_validation.validator import ( + VALID_STATE_TYPES, + ValidationCategory, + ValidationResult, + validate_asl_definition, +) + + +# --------------------------------------------------------------------------- +# Strategies for generating ASL components +# --------------------------------------------------------------------------- + +# Strategy: valid state names (non-empty alphanumeric identifiers) +state_name_st = st.text( + alphabet=string.ascii_letters + string.digits + "_", + min_size=1, + max_size=30, +) + + +def _terminal_state_def() -> st.SearchStrategy[dict]: + """Generate a terminal state definition (Succeed or Fail).""" + return st.one_of( + st.just({"Type": "Succeed"}), + st.fixed_dictionaries( + {"Type": st.just("Fail"), "Error": st.text(min_size=1, max_size=20)} + ), + ) + + +def _non_terminal_state_def(next_state: str) -> dict: + """Generate a non-terminal state definition pointing to next_state.""" + return {"Type": "Task", "Resource": "arn:aws:lambda:us-east-1:123456789012:function:test", "Next": next_state} + + +@st.composite +def valid_asl_st(draw: st.DrawFn) -> dict: + """Generate a valid ASL definition that should pass validation. + + Strategy: + - Generate 1-5 state names + - Chain them together with Next references + - The last state is terminal (Succeed or Fail) + - StartAt points to the first state + """ + num_states = draw(st.integers(min_value=1, max_value=5)) + # Generate unique state names + names = draw( + st.lists( + state_name_st, + min_size=num_states, + max_size=num_states, + unique=True, + ) + ) + + states: dict = {} + for i, name in enumerate(names): + if i == len(names) - 1: + # Last state is terminal + states[name] = draw(_terminal_state_def()) + else: + # Non-terminal state points to the next state in the chain + states[name] = _non_terminal_state_def(names[i + 1]) + + return { + "StartAt": names[0], + "States": states, + } + + +@st.composite +def valid_asl_with_choice_st(draw: st.DrawFn) -> dict: + """Generate a valid ASL with a Choice state and valid targets.""" + # At least 3 states: choice + 2 targets + target_names = draw( + st.lists(state_name_st, min_size=2, max_size=4, unique=True) + ) + choice_name = draw(state_name_st.filter(lambda n: n not in target_names)) + + choices_list = [ + { + "Variable": "$.key", + "StringEquals": f"val_{i}", + "Next": target_names[i], + } + for i in range(len(target_names)) + ] + + states: dict = { + choice_name: { + "Type": "Choice", + "Choices": choices_list, + "Default": target_names[0], + } + } + for t_name in target_names: + states[t_name] = {"Type": "Succeed"} + + return { + "StartAt": choice_name, + "States": states, + } + + +@st.composite +def syntax_error_missing_fields_st(draw: st.DrawFn) -> dict: + """Generate ASL structures with missing required fields (SYNTAX_ERROR).""" + variant = draw(st.sampled_from(["no_start_at", "no_states", "empty_states", "states_not_dict"])) + + if variant == "no_start_at": + # Missing StartAt but has States with at least one valid state + return { + "States": {"S1": {"Type": "Succeed"}} + } + elif variant == "no_states": + # Missing States + return {"StartAt": "S1"} + elif variant == "empty_states": + # Empty States dict + return {"StartAt": "S1", "States": {}} + else: + # States is not a dict + return {"StartAt": "S1", "States": draw(st.sampled_from([[], "string", 123, True]))} + + +@st.composite +def syntax_error_invalid_type_st(draw: st.DrawFn) -> dict: + """Generate ASL with invalid state types (SYNTAX_ERROR).""" + # Generate an invalid type that's not in VALID_STATE_TYPES + invalid_type = draw( + st.text(min_size=1, max_size=20).filter(lambda t: t not in VALID_STATE_TYPES) + ) + state_name = draw(state_name_st) + + return { + "StartAt": state_name, + "States": { + state_name: {"Type": invalid_type, "End": True} + }, + } + + +@st.composite +def syntax_error_missing_type_st(draw: st.DrawFn) -> dict: + """Generate ASL where a state is missing the Type field (SYNTAX_ERROR).""" + state_name = draw(state_name_st) + + return { + "StartAt": state_name, + "States": { + state_name: {"Resource": "arn:aws:lambda:us-east-1:123456789012:function:f", "End": True} + }, + } + + +@st.composite +def syntax_error_state_not_dict_st(draw: st.DrawFn) -> dict: + """Generate ASL where a state definition is not a dict (SYNTAX_ERROR).""" + state_name = draw(state_name_st) + bad_value = draw(st.sampled_from(["string", 42, True, [1, 2, 3]])) + + return { + "StartAt": state_name, + "States": { + state_name: bad_value + }, + } + + +@st.composite +def reference_error_start_at_st(draw: st.DrawFn) -> dict: + """Generate ASL where StartAt references a non-existent state (REFERENCE_ERROR).""" + actual_name = draw(state_name_st) + bogus_name = draw(state_name_st.filter(lambda n: n != actual_name)) + + return { + "StartAt": bogus_name, + "States": { + actual_name: {"Type": "Succeed"} + }, + } + + +@st.composite +def reference_error_next_st(draw: st.DrawFn) -> dict: + """Generate ASL where a Next field references a non-existent state (REFERENCE_ERROR).""" + state_name = draw(state_name_st) + bogus_target = draw(state_name_st.filter(lambda n: n != state_name)) + + return { + "StartAt": state_name, + "States": { + state_name: {"Type": "Pass", "Next": bogus_target} + }, + } + + +@st.composite +def reference_error_choice_st(draw: st.DrawFn) -> dict: + """Generate ASL where a Choice branch targets a non-existent state (REFERENCE_ERROR).""" + choice_name = draw(state_name_st) + valid_target = draw(state_name_st.filter(lambda n: n != choice_name)) + bogus_target = draw( + state_name_st.filter(lambda n: n not in (choice_name, valid_target)) + ) + + return { + "StartAt": choice_name, + "States": { + choice_name: { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.x", + "StringEquals": "y", + "Next": bogus_target, + } + ], + "Default": valid_target, + }, + valid_target: {"Type": "Succeed"}, + }, + } + + +# Strategy combining all SYNTAX_ERROR generators +syntax_error_st = st.one_of( + syntax_error_missing_fields_st(), + syntax_error_invalid_type_st(), + syntax_error_missing_type_st(), + syntax_error_state_not_dict_st(), +) + +# Strategy combining all REFERENCE_ERROR generators +reference_error_st = st.one_of( + reference_error_start_at_st(), + reference_error_next_st(), + reference_error_choice_st(), +) + +# Strategy combining all valid ASL generators +valid_st = st.one_of( + valid_asl_st(), + valid_asl_with_choice_st(), +) + +# File path strategy +file_path_st = st.text( + alphabet=string.ascii_letters + string.digits + "/._-", + min_size=1, + max_size=100, +) + + +# --------------------------------------------------------------------------- +# Property Tests +# --------------------------------------------------------------------------- + + +class TestAslValidationCategorizationProperty: + """Property 3: ASL Validation Categorization. + + For any Step Function ASL definition (valid or invalid), validate_asl_definition + SHALL return a ValidationResult with the correct ValidationCategory and non-empty + error details for non-SUCCESS categories. + """ + + @given(asl=valid_st, file_path=file_path_st) + @settings(max_examples=100) + def test_valid_asl_returns_success(self, asl: dict, file_path: str): + """Valid ASL definitions are categorized as SUCCESS with no errors.""" + result = validate_asl_definition(asl, file_path=file_path) + + assert isinstance(result, ValidationResult) + assert result.category == ValidationCategory.SUCCESS + assert result.file_path == file_path + assert result.errors == [] + + @given(asl=syntax_error_st, file_path=file_path_st) + @settings(max_examples=100) + def test_syntax_error_asl_returns_syntax_error(self, asl: dict, file_path: str): + """Malformed ASL definitions (missing fields, invalid types) are categorized + as SYNTAX_ERROR with non-empty error details.""" + result = validate_asl_definition(asl, file_path=file_path) + + assert isinstance(result, ValidationResult) + assert result.category == ValidationCategory.SYNTAX_ERROR + assert result.file_path == file_path + assert len(result.errors) > 0, "SYNTAX_ERROR must have non-empty error details" + + @given(asl=reference_error_st, file_path=file_path_st) + @settings(max_examples=100) + def test_reference_error_asl_returns_reference_error(self, asl: dict, file_path: str): + """ASL definitions with dangling references are categorized as REFERENCE_ERROR + with non-empty error details.""" + result = validate_asl_definition(asl, file_path=file_path) + + assert isinstance(result, ValidationResult) + assert result.category == ValidationCategory.REFERENCE_ERROR + assert result.file_path == file_path + assert len(result.errors) > 0, "REFERENCE_ERROR must have non-empty error details" + + @given( + asl=st.one_of(valid_st, syntax_error_st, reference_error_st), + file_path=file_path_st, + ) + @settings(max_examples=100) + def test_result_always_contains_file_path(self, asl: dict, file_path: str): + """The file_path is always present in the ValidationResult regardless of category.""" + result = validate_asl_definition(asl, file_path=file_path) + + assert isinstance(result, ValidationResult) + assert result.file_path == file_path + + @given(asl=st.one_of(syntax_error_st, reference_error_st), file_path=file_path_st) + @settings(max_examples=100) + def test_non_success_has_non_empty_errors(self, asl: dict, file_path: str): + """Any non-SUCCESS result has at least one error message.""" + result = validate_asl_definition(asl, file_path=file_path) + + assert result.category != ValidationCategory.SUCCESS + assert len(result.errors) > 0, ( + f"Category {result.category.value} must have non-empty errors" + ) diff --git a/packages/orcabus-pipeline-test-utils/tests/test_property_json_comparison.py b/packages/orcabus-pipeline-test-utils/tests/test_property_json_comparison.py new file mode 100644 index 0000000..92ddf01 --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/tests/test_property_json_comparison.py @@ -0,0 +1,313 @@ +"""Property-based tests for JSONata output comparison (match_json). + +# Feature: deployment-integration-tests, Property 9: JSONata Output Comparison + +**Validates: Requirements 6.4** + +For any pair of JSON values (actual output from JSONata evaluation and expected output), +the comparison logic SHALL return pass when the values are structurally equal (respecting +wildcard placeholders like `{% any_string %}`) and fail when they differ. +""" + +from __future__ import annotations + +from hypothesis import given, settings, assume +from hypothesis import strategies as st + +from orcabus_pipeline_test_utils.assertions import ( + ANY_STRING_PLACEHOLDER, + match_json, +) + + +# --------------------------------------------------------------------------- +# Strategies for generating JSON values +# --------------------------------------------------------------------------- + +# Strategy for JSON scalar values (no wildcard) +json_scalars = st.one_of( + st.none(), + st.booleans(), + st.integers(min_value=-1000, max_value=1000), + st.floats(allow_nan=False, allow_infinity=False, min_value=-1e6, max_value=1e6), + st.text(min_size=0, max_size=30), +) + + +def json_values_st(max_depth: int = 3) -> st.SearchStrategy: + """Generate arbitrary JSON-compatible values up to a given depth.""" + if max_depth <= 0: + return json_scalars + return st.one_of( + json_scalars, + st.lists(json_values_st(max_depth - 1), max_size=4), + st.dictionaries( + keys=st.text(min_size=1, max_size=10), + values=json_values_st(max_depth - 1), + max_size=4, + ), + ) + + +# Strategy for non-empty strings (used as actual values matching wildcards) +non_empty_strings = st.text(min_size=1, max_size=30) + + +@st.composite +def json_with_wildcards_st(draw: st.DrawFn) -> tuple: + """Generate a pair of (actual, expected) JSON values where expected may contain wildcards. + + Returns (actual, expected) such that actual matches expected (respecting wildcards). + """ + # Decide the structure type + structure = draw(st.sampled_from(["scalar", "dict", "list", "nested_dict"])) + + if structure == "scalar": + # Either exact match or wildcard + use_wildcard = draw(st.booleans()) + if use_wildcard: + actual_val = draw(non_empty_strings) + return (actual_val, ANY_STRING_PLACEHOLDER) + else: + val = draw(json_scalars) + return (val, val) + + elif structure == "dict": + # Generate a dict with some wildcard fields + num_keys = draw(st.integers(min_value=1, max_value=5)) + keys = draw( + st.lists( + st.text(min_size=1, max_size=10), + min_size=num_keys, + max_size=num_keys, + unique=True, + ) + ) + actual_dict: dict = {} + expected_dict: dict = {} + for key in keys: + use_wildcard = draw(st.booleans()) + if use_wildcard: + actual_dict[key] = draw(non_empty_strings) + expected_dict[key] = ANY_STRING_PLACEHOLDER + else: + val = draw(json_scalars) + actual_dict[key] = val + expected_dict[key] = val + return (actual_dict, expected_dict) + + elif structure == "list": + # Generate a list with some wildcard elements + num_items = draw(st.integers(min_value=0, max_value=5)) + actual_list: list = [] + expected_list: list = [] + for _ in range(num_items): + use_wildcard = draw(st.booleans()) + if use_wildcard: + actual_list.append(draw(non_empty_strings)) + expected_list.append(ANY_STRING_PLACEHOLDER) + else: + val = draw(json_scalars) + actual_list.append(val) + expected_list.append(val) + return (actual_list, expected_list) + + else: # nested_dict + # Generate a nested dict with wildcards at leaf level + outer_keys = draw( + st.lists( + st.text(min_size=1, max_size=8), + min_size=1, + max_size=3, + unique=True, + ) + ) + actual_outer: dict = {} + expected_outer: dict = {} + for okey in outer_keys: + inner_keys = draw( + st.lists( + st.text(min_size=1, max_size=8), + min_size=1, + max_size=3, + unique=True, + ) + ) + actual_inner: dict = {} + expected_inner: dict = {} + for ikey in inner_keys: + use_wildcard = draw(st.booleans()) + if use_wildcard: + actual_inner[ikey] = draw(non_empty_strings) + expected_inner[ikey] = ANY_STRING_PLACEHOLDER + else: + val = draw(json_scalars) + actual_inner[ikey] = val + expected_inner[ikey] = val + actual_outer[okey] = actual_inner + expected_outer[okey] = expected_inner + return (actual_outer, expected_outer) + + +@st.composite +def mismatched_json_pair_st(draw: st.DrawFn) -> tuple: + """Generate a pair of (actual, expected) JSON values that differ structurally. + + Returns (actual, expected) where match_json should report errors. + """ + mismatch_type = draw( + st.sampled_from([ + "scalar_value_mismatch", + "missing_key", + "extra_key", + "type_mismatch", + "array_length_mismatch", + "wildcard_non_string", + "nested_mismatch", + ]) + ) + + if mismatch_type == "scalar_value_mismatch": + val1 = draw(json_scalars) + val2 = draw(json_scalars.filter(lambda v: v != val1)) + return (val1, val2) + + elif mismatch_type == "missing_key": + # actual has fewer keys than expected + key1 = draw(st.text(min_size=1, max_size=10)) + key2 = draw(st.text(min_size=1, max_size=10).filter(lambda k: k != key1)) + val = draw(json_scalars) + actual = {key1: val} + expected = {key1: val, key2: draw(json_scalars)} + return (actual, expected) + + elif mismatch_type == "extra_key": + # actual has more keys than expected + key1 = draw(st.text(min_size=1, max_size=10)) + key2 = draw(st.text(min_size=1, max_size=10).filter(lambda k: k != key1)) + val = draw(json_scalars) + actual = {key1: val, key2: draw(json_scalars)} + expected = {key1: val} + return (actual, expected) + + elif mismatch_type == "type_mismatch": + # actual is a different JSON type than expected + variant = draw(st.sampled_from(["dict_vs_list", "list_vs_dict", "scalar_vs_dict"])) + if variant == "dict_vs_list": + actual = [1, 2, 3] + expected = {"key": "value"} + return (actual, expected) + elif variant == "list_vs_dict": + actual = {"key": "value"} + expected = [1, 2, 3] + return (actual, expected) + else: + actual = draw(st.integers()) + expected = {"key": "value"} + return (actual, expected) + + elif mismatch_type == "array_length_mismatch": + # Arrays of different lengths + len1 = draw(st.integers(min_value=0, max_value=5)) + len2 = draw(st.integers(min_value=0, max_value=5).filter(lambda l: l != len1)) + actual = [draw(json_scalars) for _ in range(len1)] + expected = [draw(json_scalars) for _ in range(len2)] + return (actual, expected) + + elif mismatch_type == "wildcard_non_string": + # Wildcard expects a string but actual is not a string + non_string_val = draw( + st.one_of( + st.integers(), + st.booleans(), + st.none(), + st.lists(st.integers(), max_size=3), + st.dictionaries(st.text(min_size=1, max_size=5), st.integers(), max_size=2), + ) + ) + return (non_string_val, ANY_STRING_PLACEHOLDER) + + else: # nested_mismatch + # Nested dict where an inner value differs + key = draw(st.text(min_size=1, max_size=10)) + inner_key = draw(st.text(min_size=1, max_size=10)) + val1 = draw(st.text(min_size=1, max_size=10)) + val2 = draw(st.text(min_size=1, max_size=10).filter(lambda v: v != val1)) + actual = {key: {inner_key: val1}} + expected = {key: {inner_key: val2}} + return (actual, expected) + + +# --------------------------------------------------------------------------- +# Property Tests +# --------------------------------------------------------------------------- + + +class TestJsonataOutputComparisonProperty: + """Property 9: JSONata Output Comparison. + + For any pair of JSON values (actual output and expected output), the comparison + logic SHALL return pass when the values are structurally equal (respecting wildcard + placeholders like `{% any_string %}`) and fail when they differ. + """ + + @given(pair=json_with_wildcards_st()) + @settings(max_examples=100) + def test_structurally_equal_values_return_no_errors(self, pair: tuple): + """When actual matches expected (respecting wildcards), match_json returns + an empty error list (pass).""" + actual, expected = pair + errors = match_json(actual, expected) + assert errors == [], ( + f"Expected no errors for matching pair but got: {errors}\n" + f"actual={actual!r}, expected={expected!r}" + ) + + @given(pair=mismatched_json_pair_st()) + @settings(max_examples=100) + def test_structurally_different_values_return_errors(self, pair: tuple): + """When actual differs from expected, match_json returns a non-empty error list (fail).""" + actual, expected = pair + errors = match_json(actual, expected) + assert len(errors) > 0, ( + f"Expected errors for mismatched pair but got none.\n" + f"actual={actual!r}, expected={expected!r}" + ) + + @given(value=json_values_st(max_depth=3)) + @settings(max_examples=100) + def test_value_matches_itself(self, value): + """Any JSON value compared against itself returns no errors (reflexivity).""" + errors = match_json(value, value) + assert errors == [], ( + f"Expected no errors for self-comparison but got: {errors}\n" + f"value={value!r}" + ) + + @given(actual_str=non_empty_strings) + @settings(max_examples=100) + def test_wildcard_matches_any_string(self, actual_str: str): + """The wildcard placeholder matches any string value.""" + errors = match_json(actual_str, ANY_STRING_PLACEHOLDER) + assert errors == [], ( + f"Wildcard should match any string, but got errors: {errors}\n" + f"actual_str={actual_str!r}" + ) + + @given( + non_string=st.one_of( + st.integers(), + st.booleans(), + st.none(), + st.lists(st.integers(), max_size=3), + st.dictionaries(st.text(min_size=1, max_size=5), st.integers(), max_size=2), + ) + ) + @settings(max_examples=100) + def test_wildcard_rejects_non_string(self, non_string): + """The wildcard placeholder fails when actual is not a string.""" + errors = match_json(non_string, ANY_STRING_PLACEHOLDER) + assert len(errors) > 0, ( + f"Wildcard should reject non-string values but got no errors.\n" + f"non_string={non_string!r}" + ) diff --git a/packages/orcabus-pipeline-test-utils/tests/test_property_lambda_arn_refs.py b/packages/orcabus-pipeline-test-utils/tests/test_property_lambda_arn_refs.py new file mode 100644 index 0000000..848cb25 --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/tests/test_property_lambda_arn_refs.py @@ -0,0 +1,170 @@ +"""Property-based test for Lambda ARN Placeholder Cross-Reference. + +# Feature: deployment-integration-tests, Property 5: Lambda ARN Placeholder Cross-Reference + +**Validates: Requirements 4.4** + +Property 5: For any ASL definition containing Lambda ARN placeholders and any CDK +lambda configuration map, the check_lambda_arn_references function SHALL return an +error for every placeholder in the ASL that has no corresponding entry in the config +map, and SHALL return no errors when all placeholders are present in the config map. +""" + +from __future__ import annotations + +import json + +from hypothesis import given, settings +from hypothesis import strategies as st + +from orcabus_pipeline_test_utils.asl_validation.reference_checker import ( + check_lambda_arn_references, +) + + +# --- Strategies --- + +# Generate valid lambda names: lowercase letters and underscores, 1-30 chars +lambda_name_strategy = st.from_regex(r"[a-z][a-z0-9_]{0,29}", fullmatch=True) + + +def lambda_placeholder(name: str) -> str: + """Build a Lambda ARN placeholder string from a name.""" + return f"${{__{name}_lambda_function_arn__}}" + + +def make_asl_with_placeholders(placeholder_names: list[str]) -> dict: + """Build a minimal ASL definition that uses the given Lambda ARN placeholders.""" + if not placeholder_names: + return { + "StartAt": "PassState", + "States": { + "PassState": { + "Type": "Pass", + "End": True, + } + }, + } + + states = {} + for i, name in enumerate(placeholder_names): + is_last = i == len(placeholder_names) - 1 + state_def: dict = { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Arguments": { + "FunctionName": lambda_placeholder(name), + "Payload": {"key": "value"}, + }, + } + if is_last: + state_def["End"] = True + else: + state_def["Next"] = f"State{i + 1}" + states[f"State{i}"] = state_def + + return { + "StartAt": "State0", + "States": states, + } + + +def make_cdk_config(names: list[str]) -> dict: + """Build a CDK lambda config map for the given lambda names.""" + return { + "lambdas": { + name: { + "placeholder": lambda_placeholder(name), + "entry": f"app/lambdas/{name}_py", + } + for name in names + } + } + + +@settings(max_examples=100) +@given( + asl_names=st.lists(lambda_name_strategy, min_size=0, max_size=10, unique=True), + config_names=st.lists(lambda_name_strategy, min_size=0, max_size=10, unique=True), +) +def test_lambda_arn_cross_reference_errors_for_missing_placeholders( + asl_names: list[str], + config_names: list[str], +) -> None: + """Property 5: errors for placeholders missing from config, no errors when all present. + + For any set of Lambda ARN placeholders in an ASL definition and any CDK config map: + - Every placeholder in the ASL that has NO corresponding entry in the config + SHALL produce an error. + - When all placeholders ARE present in the config map, there SHALL be no errors. + """ + asl_json = make_asl_with_placeholders(asl_names) + cdk_config = make_cdk_config(config_names) + + errors = check_lambda_arn_references(asl_json, cdk_config) + + # Determine which ASL placeholders are missing from the config + config_placeholder_set = {lambda_placeholder(n) for n in config_names} + asl_placeholder_set = {lambda_placeholder(n) for n in asl_names} + + missing_placeholders = asl_placeholder_set - config_placeholder_set + + # The number of errors should equal the number of missing placeholders + assert len(errors) == len(missing_placeholders), ( + f"Expected {len(missing_placeholders)} errors for missing placeholders, " + f"got {len(errors)}. " + f"ASL placeholders: {asl_placeholder_set}, " + f"Config placeholders: {config_placeholder_set}, " + f"Errors: {errors}" + ) + + # Each missing placeholder should appear in exactly one error message + for placeholder in missing_placeholders: + matching_errors = [e for e in errors if placeholder in e] + assert len(matching_errors) == 1, ( + f"Expected exactly one error mentioning '{placeholder}', " + f"got {len(matching_errors)}. Errors: {errors}" + ) + + # No error should mention a placeholder that IS in the config + present_placeholders = asl_placeholder_set & config_placeholder_set + for placeholder in present_placeholders: + matching_errors = [e for e in errors if placeholder in e] + assert len(matching_errors) == 0, ( + f"Placeholder '{placeholder}' is in config but appears in error: " + f"{matching_errors}" + ) + + +@settings(max_examples=100) +@given( + names=st.lists(lambda_name_strategy, min_size=1, max_size=10, unique=True), +) +def test_lambda_arn_no_errors_when_all_present(names: list[str]) -> None: + """When all ASL placeholders are present in the config, no errors are returned.""" + asl_json = make_asl_with_placeholders(names) + cdk_config = make_cdk_config(names) + + errors = check_lambda_arn_references(asl_json, cdk_config) + + assert errors == [], ( + f"Expected no errors when all placeholders are in config, " + f"but got: {errors}" + ) + + +@settings(max_examples=100) +@given( + asl_names=st.lists(lambda_name_strategy, min_size=1, max_size=10, unique=True), +) +def test_lambda_arn_all_errors_when_config_empty(asl_names: list[str]) -> None: + """When config is empty, every ASL placeholder produces an error.""" + asl_json = make_asl_with_placeholders(asl_names) + cdk_config = make_cdk_config([]) + + errors = check_lambda_arn_references(asl_json, cdk_config) + + assert len(errors) == len(asl_names), ( + f"Expected {len(asl_names)} errors for all-missing placeholders, " + f"got {len(errors)}. Errors: {errors}" + ) diff --git a/packages/orcabus-pipeline-test-utils/tests/test_property_smoke_classification.py b/packages/orcabus-pipeline-test-utils/tests/test_property_smoke_classification.py new file mode 100644 index 0000000..043f942 --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/tests/test_property_smoke_classification.py @@ -0,0 +1,298 @@ +"""Property-based tests for smoke test error classification. + +# Feature: deployment-integration-tests, Property 12: Smoke Test Error Classification + +**Validates: Requirements 8.7, 8.8** + +For any boto3 exception raised during a smoke test, the smoke test SHALL classify +`AccessDeniedException` and `ExpiredTokenException` as `error_type="auth"` and all +other failures (ResourceNotFoundException, InvalidParameterException, etc.) as +`error_type="config"`, and SHALL include the resource name, resource type, and +original error message in the result. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import boto3 +from botocore.exceptions import ClientError +from botocore.stub import Stubber +from hypothesis import given, settings +from hypothesis import strategies as st + +from orcabus_pipeline_test_utils.smoke.lambda_check import check_lambda_invocable +from orcabus_pipeline_test_utils.smoke.sfn_check import check_state_machine_active +from orcabus_pipeline_test_utils.smoke.ssm_check import check_ssm_parameters_exist + + +# --------------------------------------------------------------------------- +# Strategies +# --------------------------------------------------------------------------- + +# Auth error codes that should be classified as error_type="auth" +AUTH_ERROR_CODES = ["AccessDeniedException", "ExpiredTokenException"] + +# Non-auth error codes that should be classified as error_type="config" +NON_AUTH_ERROR_CODES = [ + "ResourceNotFoundException", + "InvalidParameterException", + "InvalidParameterValueException", + "ServiceException", + "TooManyRequestsException", + "InvalidRequestContentException", + "RequestTooLargeException", + "ResourceConflictException", + "StateMachineDoesNotExist", + "ParameterNotFound", + "InternalServerError", + "ThrottlingException", + "ValidationException", +] + +# Strategy: generate auth error codes +auth_error_code_st = st.sampled_from(AUTH_ERROR_CODES) + +# Strategy: generate non-auth error codes +non_auth_error_code_st = st.sampled_from(NON_AUTH_ERROR_CODES) + +# Strategy: generate error messages +error_message_st = st.text( + alphabet="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 .:/-_", + min_size=5, + max_size=100, +) + +# Strategy: generate resource names (function names, ARNs, SSM paths) +resource_name_st = st.text( + alphabet="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_/:", + min_size=3, + max_size=60, +) + +# Strategy: smoke check type +smoke_check_type_st = st.sampled_from(["lambda", "state_machine", "ssm_parameter"]) + + +def _make_client_error(error_code: str, error_message: str) -> ClientError: + """Create a botocore ClientError with the given code and message.""" + return ClientError( + error_response={ + "Error": { + "Code": error_code, + "Message": error_message, + } + }, + operation_name="TestOperation", + ) + + +# --------------------------------------------------------------------------- +# Property Tests +# --------------------------------------------------------------------------- + + +class TestSmokeTestErrorClassificationProperty: + """Property 12: Smoke Test Error Classification. + + For any boto3 exception raised during a smoke test, the smoke test SHALL + classify AccessDeniedException and ExpiredTokenException as error_type="auth" + and all other failures as error_type="config", and SHALL include the resource + name, resource type, and original error message in the result. + """ + + @given( + error_code=auth_error_code_st, + error_message=error_message_st, + resource_name=resource_name_st, + ) + @settings(max_examples=100) + def test_auth_errors_classified_as_auth_for_lambda( + self, error_code: str, error_message: str, resource_name: str + ): + """Auth error codes produce error_type='auth' for Lambda checks.""" + session = MagicMock(spec=boto3.Session) + mock_client = MagicMock() + mock_client.invoke.side_effect = _make_client_error(error_code, error_message) + session.client.return_value = mock_client + + result = check_lambda_invocable(resource_name, session) + + assert result.passed is False + assert result.error_type == "auth" + assert result.resource_name == resource_name + assert result.resource_type == "lambda" + assert result.error_message is not None + assert error_message in result.error_message + + @given( + error_code=non_auth_error_code_st, + error_message=error_message_st, + resource_name=resource_name_st, + ) + @settings(max_examples=100) + def test_non_auth_errors_classified_as_config_for_lambda( + self, error_code: str, error_message: str, resource_name: str + ): + """Non-auth error codes produce error_type='config' for Lambda checks.""" + session = MagicMock(spec=boto3.Session) + mock_client = MagicMock() + mock_client.invoke.side_effect = _make_client_error(error_code, error_message) + session.client.return_value = mock_client + + result = check_lambda_invocable(resource_name, session) + + assert result.passed is False + assert result.error_type == "config" + assert result.resource_name == resource_name + assert result.resource_type == "lambda" + assert result.error_message is not None + assert error_message in result.error_message + + @given( + error_code=auth_error_code_st, + error_message=error_message_st, + resource_name=resource_name_st, + ) + @settings(max_examples=100) + def test_auth_errors_classified_as_auth_for_sfn( + self, error_code: str, error_message: str, resource_name: str + ): + """Auth error codes produce error_type='auth' for Step Function checks.""" + session = MagicMock(spec=boto3.Session) + mock_client = MagicMock() + mock_client.describe_state_machine.side_effect = _make_client_error( + error_code, error_message + ) + session.client.return_value = mock_client + + result = check_state_machine_active(resource_name, session) + + assert result.passed is False + assert result.error_type == "auth" + assert result.resource_name == resource_name + assert result.resource_type == "state_machine" + assert result.error_message is not None + assert error_message in result.error_message + + @given( + error_code=non_auth_error_code_st, + error_message=error_message_st, + resource_name=resource_name_st, + ) + @settings(max_examples=100) + def test_non_auth_errors_classified_as_config_for_sfn( + self, error_code: str, error_message: str, resource_name: str + ): + """Non-auth error codes produce error_type='config' for Step Function checks.""" + session = MagicMock(spec=boto3.Session) + mock_client = MagicMock() + mock_client.describe_state_machine.side_effect = _make_client_error( + error_code, error_message + ) + session.client.return_value = mock_client + + result = check_state_machine_active(resource_name, session) + + assert result.passed is False + assert result.error_type == "config" + assert result.resource_name == resource_name + assert result.resource_type == "state_machine" + assert result.error_message is not None + assert error_message in result.error_message + + @given( + error_code=auth_error_code_st, + error_message=error_message_st, + resource_name=resource_name_st, + ) + @settings(max_examples=100) + def test_auth_errors_classified_as_auth_for_ssm( + self, error_code: str, error_message: str, resource_name: str + ): + """Auth error codes produce error_type='auth' for SSM checks.""" + session = MagicMock(spec=boto3.Session) + mock_client = MagicMock() + mock_client.get_parameter.side_effect = _make_client_error( + error_code, error_message + ) + session.client.return_value = mock_client + + results = check_ssm_parameters_exist([resource_name], session) + + assert len(results) == 1 + result = results[0] + assert result.passed is False + assert result.error_type == "auth" + assert result.resource_name == resource_name + assert result.resource_type == "ssm_parameter" + assert result.error_message is not None + assert error_message in result.error_message + + @given( + error_code=non_auth_error_code_st, + error_message=error_message_st, + resource_name=resource_name_st, + ) + @settings(max_examples=100) + def test_non_auth_errors_classified_as_config_for_ssm( + self, error_code: str, error_message: str, resource_name: str + ): + """Non-auth error codes produce error_type='config' for SSM checks.""" + session = MagicMock(spec=boto3.Session) + mock_client = MagicMock() + mock_client.get_parameter.side_effect = _make_client_error( + error_code, error_message + ) + session.client.return_value = mock_client + + results = check_ssm_parameters_exist([resource_name], session) + + assert len(results) == 1 + result = results[0] + assert result.passed is False + assert result.error_type == "config" + assert result.resource_name == resource_name + assert result.resource_type == "ssm_parameter" + assert result.error_message is not None + assert error_message in result.error_message + + @given( + error_code=st.sampled_from(AUTH_ERROR_CODES + NON_AUTH_ERROR_CODES), + error_message=error_message_st, + resource_name=resource_name_st, + check_type=smoke_check_type_st, + ) + @settings(max_examples=100) + def test_error_classification_is_consistent_across_resource_types( + self, + error_code: str, + error_message: str, + resource_name: str, + check_type: str, + ): + """The same error code produces the same classification regardless of resource type.""" + expected_error_type = "auth" if error_code in AUTH_ERROR_CODES else "config" + + session = MagicMock(spec=boto3.Session) + mock_client = MagicMock() + client_error = _make_client_error(error_code, error_message) + + if check_type == "lambda": + mock_client.invoke.side_effect = client_error + session.client.return_value = mock_client + result = check_lambda_invocable(resource_name, session) + elif check_type == "state_machine": + mock_client.describe_state_machine.side_effect = client_error + session.client.return_value = mock_client + result = check_state_machine_active(resource_name, session) + else: # ssm_parameter + mock_client.get_parameter.side_effect = client_error + session.client.return_value = mock_client + results = check_ssm_parameters_exist([resource_name], session) + result = results[0] + + assert result.passed is False + assert result.error_type == expected_error_type + assert result.resource_name == resource_name + assert result.error_message is not None diff --git a/packages/orcabus-pipeline-test-utils/tests/test_property_smoke_validation.py b/packages/orcabus-pipeline-test-utils/tests/test_property_smoke_validation.py new file mode 100644 index 0000000..7828902 --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/tests/test_property_smoke_validation.py @@ -0,0 +1,291 @@ +"""Property-based tests for smoke check resource validation. + +# Feature: deployment-integration-tests, Property 11: Smoke Check Resource Validation + +**Validates: Requirements 8.3, 8.4, 8.5** + +For any AWS resource check (Lambda DryRun, DescribeStateMachine, or SSM GetParameter) +and any mocked boto3 response, the corresponding smoke check function SHALL return +`passed=True` only when the response indicates the resource is correctly configured +(HTTP 204 for Lambda, status=ACTIVE with non-null definition for state machines, +successful GetParameter for SSM), and SHALL return `passed=False` with a descriptive +`error_message` for all other responses. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import boto3 +from botocore.exceptions import ClientError +from botocore.stub import Stubber +from hypothesis import given, settings +from hypothesis import strategies as st +from moto import mock_aws + +from orcabus_pipeline_test_utils.smoke import SmokeTestResult +from orcabus_pipeline_test_utils.smoke.lambda_check import check_lambda_invocable +from orcabus_pipeline_test_utils.smoke.sfn_check import check_state_machine_active +from orcabus_pipeline_test_utils.smoke.ssm_check import check_ssm_parameters_exist + + +# --------------------------------------------------------------------------- +# Strategies +# --------------------------------------------------------------------------- + +# HTTP status codes: 204 is the only passing code for Lambda DryRun +http_status_code_st = st.integers(min_value=100, max_value=599) + +# Non-empty function names / ARNs +function_name_st = st.text( + alphabet="abcdefghijklmnopqrstuvwxyz0123456789-_", + min_size=1, + max_size=50, +) + +# State machine ARN-like strings +state_machine_arn_st = st.text( + alphabet="abcdefghijklmnopqrstuvwxyz0123456789:/-", + min_size=1, + max_size=80, +).map(lambda s: f"arn:aws:states:ap-southeast-2:123456789012:stateMachine:{s}") + +# State machine statuses +sfn_status_st = st.sampled_from(["ACTIVE", "DELETING", "INACTIVE"]) + +# State machine definitions: non-null non-empty strings or empty/None +valid_definition_st = st.text( + alphabet="abcdefghijklmnopqrstuvwxyz{}:\"',", + min_size=1, + max_size=100, +) + +null_or_empty_definition_st = st.sampled_from([None, ""]) + +# SSM parameter paths +ssm_path_st = st.text( + alphabet="abcdefghijklmnopqrstuvwxyz0123456789/-_", + min_size=1, + max_size=60, +).map(lambda s: f"/orcabus/test/{s}") + + +# --------------------------------------------------------------------------- +# Property Tests: Lambda DryRun +# --------------------------------------------------------------------------- + + +class TestLambdaDryRunResourceValidationProperty: + """Property 11 (Lambda): passed=True only when HTTP 204 is returned.""" + + @given(status_code=http_status_code_st, func_name=function_name_st) + @settings(max_examples=100) + def test_lambda_passed_iff_status_204( + self, status_code: int, func_name: str + ): + """check_lambda_invocable returns passed=True if and only if HTTP 204.""" + session = boto3.Session(region_name="ap-southeast-2") + client = session.client("lambda") + + with Stubber(client) as stubber: + stubber.add_response( + "invoke", + {"StatusCode": status_code}, + expected_params={ + "FunctionName": func_name, + "InvocationType": "DryRun", + }, + ) + + with patch.object(session, "client", return_value=client): + result = check_lambda_invocable(func_name, session) + + assert result.resource_name == func_name + assert result.resource_type == "lambda" + + if status_code == 204: + assert result.passed is True, ( + f"Expected passed=True for HTTP 204, got passed=False " + f"with error_message={result.error_message!r}" + ) + assert result.error_type is None + assert result.error_message is None + else: + assert result.passed is False, ( + f"Expected passed=False for HTTP {status_code}, got passed=True" + ) + assert result.error_message is not None + assert result.error_type == "config" + + @given(func_name=function_name_st) + @settings(max_examples=100) + def test_lambda_client_error_always_fails(self, func_name: str): + """Any ClientError during Lambda invoke should result in passed=False.""" + session = boto3.Session(region_name="ap-southeast-2") + mock_client = MagicMock() + mock_client.invoke.side_effect = ClientError( + error_response={ + "Error": { + "Code": "ResourceNotFoundException", + "Message": f"Function not found: {func_name}", + } + }, + operation_name="Invoke", + ) + + with patch.object(session, "client", return_value=mock_client): + result = check_lambda_invocable(func_name, session) + + assert result.passed is False + assert result.resource_name == func_name + assert result.resource_type == "lambda" + assert result.error_message is not None + assert len(result.error_message) > 0 + + +# --------------------------------------------------------------------------- +# Property Tests: State Machine +# --------------------------------------------------------------------------- + + +class TestStateMachineResourceValidationProperty: + """Property 11 (SFN): passed=True only when status=ACTIVE and definition is non-null/non-empty.""" + + @given( + sm_arn=state_machine_arn_st, + status=sfn_status_st, + definition=st.one_of(valid_definition_st, null_or_empty_definition_st), + ) + @settings(max_examples=100) + def test_sfn_passed_iff_active_with_definition( + self, sm_arn: str, status: str, definition: str | None + ): + """check_state_machine_active returns passed=True iff status=ACTIVE and definition truthy.""" + session = MagicMock() + mock_client = MagicMock() + session.client.return_value = mock_client + + mock_client.describe_state_machine.return_value = { + "stateMachineArn": sm_arn, + "status": status, + "definition": definition, + "name": "test-sm", + } + + result = check_state_machine_active(sm_arn, session) + + assert result.resource_name == sm_arn + assert result.resource_type == "state_machine" + + should_pass = status == "ACTIVE" and bool(definition) + + if should_pass: + assert result.passed is True, ( + f"Expected passed=True for status={status!r}, " + f"definition={definition!r}, but got passed=False " + f"with error_message={result.error_message!r}" + ) + assert result.error_type is None + assert result.error_message is None + else: + assert result.passed is False, ( + f"Expected passed=False for status={status!r}, " + f"definition={definition!r}, but got passed=True" + ) + assert result.error_type == "config" + assert result.error_message is not None + assert len(result.error_message) > 0 + + @given(sm_arn=state_machine_arn_st) + @settings(max_examples=100) + def test_sfn_client_error_always_fails(self, sm_arn: str): + """Any ClientError during DescribeStateMachine should result in passed=False.""" + session = MagicMock() + mock_client = MagicMock() + session.client.return_value = mock_client + + mock_client.describe_state_machine.side_effect = ClientError( + error_response={ + "Error": { + "Code": "StateMachineDoesNotExist", + "Message": "State machine does not exist", + } + }, + operation_name="DescribeStateMachine", + ) + + result = check_state_machine_active(sm_arn, session) + + assert result.passed is False + assert result.resource_name == sm_arn + assert result.resource_type == "state_machine" + assert result.error_message is not None + assert len(result.error_message) > 0 + + +# --------------------------------------------------------------------------- +# Property Tests: SSM GetParameter +# --------------------------------------------------------------------------- + + +class TestSsmParameterResourceValidationProperty: + """Property 11 (SSM): passed=True only when GetParameter succeeds.""" + + @given( + param_path=ssm_path_st, + param_exists=st.booleans(), + ) + @settings(max_examples=100, deadline=None) + def test_ssm_passed_iff_get_parameter_succeeds( + self, param_path: str, param_exists: bool + ): + """check_ssm_parameters_exist returns passed=True iff GetParameter succeeds.""" + with mock_aws(): + session = boto3.Session(region_name="ap-southeast-2") + client = session.client("ssm") + + if param_exists: + client.put_parameter( + Name=param_path, + Value="test-value", + Type="String", + Overwrite=True, + ) + + results = check_ssm_parameters_exist([param_path], session) + + assert len(results) == 1 + result = results[0] + assert result.resource_name == param_path + assert result.resource_type == "ssm_parameter" + + if param_exists: + assert result.passed is True, ( + f"Expected passed=True for existing param {param_path!r}, " + f"but got passed=False with error_message={result.error_message!r}" + ) + assert result.error_type is None + assert result.error_message is None + else: + assert result.passed is False, ( + f"Expected passed=False for missing param {param_path!r}, " + f"but got passed=True" + ) + assert result.error_type == "config" + assert result.error_message is not None + assert len(result.error_message) > 0 + + @given( + paths=st.lists(ssm_path_st, min_size=1, max_size=5, unique=True), + ) + @settings(max_examples=100, deadline=None) + def test_ssm_result_count_matches_input_count(self, paths: list[str]): + """The number of results always equals the number of input paths.""" + with mock_aws(): + session = boto3.Session(region_name="ap-southeast-2") + results = check_ssm_parameters_exist(paths, session) + + assert len(results) == len(paths) + for result, path in zip(results, paths): + assert result.resource_name == path + assert result.resource_type == "ssm_parameter" diff --git a/packages/orcabus-pipeline-test-utils/tests/test_property_state_references.py b/packages/orcabus-pipeline-test-utils/tests/test_property_state_references.py new file mode 100644 index 0000000..abbc23a --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/tests/test_property_state_references.py @@ -0,0 +1,324 @@ +"""Property-based tests for state reference resolution. + +# Feature: deployment-integration-tests, Property 4: State Reference Resolution + +Validates: Requirements 4.3 + +For any ASL definition JSON containing "Next", "Default", and Choice branch target +fields, the `check_state_references` function SHALL return an error for every target +value that does not exist as a key in the "States" map, and SHALL return no errors +when all targets reference existing states. +""" + +from __future__ import annotations + +import hypothesis +from hypothesis import given, settings, assume +from hypothesis import strategies as st + +from orcabus_pipeline_test_utils.asl_validation.reference_checker import ( + check_state_references, +) + + +# --- Strategies --- + +# Generate valid state names (non-empty, alphanumeric with limited special chars) +state_name_strategy = st.text( + alphabet=st.characters(whitelist_categories=("L", "N"), whitelist_characters="_-"), + min_size=1, + max_size=30, +).filter(lambda s: s.strip() != "") + + +def _build_asl_all_valid(state_names: list[str]) -> dict: + """Build an ASL definition where all references are valid. + + Creates a linear chain of Task states ending with a Succeed state. + All Next references point to the subsequent state in the chain. + """ + if len(state_names) < 2: + return { + "StartAt": state_names[0], + "States": {state_names[0]: {"Type": "Succeed"}}, + } + + states = {} + for i, name in enumerate(state_names[:-1]): + states[name] = {"Type": "Task", "Next": state_names[i + 1]} + states[state_names[-1]] = {"Type": "Succeed"} + + return {"StartAt": state_names[0], "States": states} + + +def _build_asl_with_choice_all_valid( + state_names: list[str], + choice_state_name: str, + branch_targets: list[str], + default_target: str, +) -> dict: + """Build an ASL with a Choice state where all targets are valid. + + All branch_targets and default_target must be in state_names. + """ + states = {} + # Choice state + choices = [ + {"Variable": "$.x", "NumericEquals": i, "Next": target} + for i, target in enumerate(branch_targets) + ] + states[choice_state_name] = { + "Type": "Choice", + "Choices": choices, + "Default": default_target, + } + # All referenced states as Succeed + for name in state_names: + if name != choice_state_name: + states[name] = {"Type": "Succeed"} + + return {"StartAt": choice_state_name, "States": states} + + +@st.composite +def valid_linear_asl(draw: st.DrawFn) -> dict: + """Strategy to generate a valid linear ASL definition with no dangling references.""" + num_states = draw(st.integers(min_value=2, max_value=10)) + names = draw( + st.lists( + state_name_strategy, + min_size=num_states, + max_size=num_states, + unique=True, + ) + ) + return _build_asl_all_valid(names) + + +@st.composite +def valid_choice_asl(draw: st.DrawFn) -> dict: + """Strategy to generate a valid ASL with Choice state, all targets valid.""" + num_targets = draw(st.integers(min_value=1, max_value=5)) + # Generate unique state names: choice state + target states + all_names = draw( + st.lists( + state_name_strategy, + min_size=num_targets + 2, + max_size=num_targets + 5, + unique=True, + ) + ) + choice_name = all_names[0] + # Pick branch targets and default from the remaining names + remaining = all_names[1:] + branch_targets = remaining[:num_targets] + default_target = remaining[-1] + + return _build_asl_with_choice_all_valid( + state_names=all_names, + choice_state_name=choice_name, + branch_targets=branch_targets, + default_target=default_target, + ) + + +@st.composite +def asl_with_dangling_next(draw: st.DrawFn) -> tuple[dict, set[str]]: + """Strategy to generate an ASL with some dangling Next references. + + Returns the ASL and the set of dangling target names that are actually + referenced in the ASL's Next fields. + """ + num_valid = draw(st.integers(min_value=2, max_value=6)) + num_dangling = draw(st.integers(min_value=1, max_value=4)) + + valid_names = draw( + st.lists( + state_name_strategy, + min_size=num_valid, + max_size=num_valid, + unique=True, + ) + ) + # Generate dangling names that are NOT in valid_names + dangling_names = draw( + st.lists( + state_name_strategy, + min_size=num_dangling, + max_size=num_dangling, + unique=True, + ) + ) + # Ensure dangling names are truly not in the valid set + dangling_names = [n for n in dangling_names if n not in valid_names] + assume(len(dangling_names) >= 1) + + states = {} + actually_referenced_dangling = set() + + # First state references a dangling target + states[valid_names[0]] = {"Type": "Task", "Next": dangling_names[0]} + actually_referenced_dangling.add(dangling_names[0]) + + # Remaining valid states — some get dangling Next, others are Succeed + for i, name in enumerate(valid_names[1:], start=1): + dangling_idx = i # offset into dangling_names (skip index 0, already used) + if dangling_idx < len(dangling_names): + states[name] = {"Type": "Task", "Next": dangling_names[dangling_idx]} + actually_referenced_dangling.add(dangling_names[dangling_idx]) + else: + states[name] = {"Type": "Succeed"} + + asl = {"StartAt": valid_names[0], "States": states} + + return asl, actually_referenced_dangling + + +@st.composite +def asl_with_dangling_default(draw: st.DrawFn) -> tuple[dict, str]: + """Strategy to generate an ASL with a Choice state that has a dangling Default. + + Returns the ASL and the dangling default target name. + """ + num_states = draw(st.integers(min_value=2, max_value=6)) + names = draw( + st.lists( + state_name_strategy, + min_size=num_states, + max_size=num_states, + unique=True, + ) + ) + dangling_default = draw(state_name_strategy) + assume(dangling_default not in names) + + choice_name = names[0] + # Valid branch targets from remaining names + branch_target = names[1] + + states = {} + states[choice_name] = { + "Type": "Choice", + "Choices": [ + {"Variable": "$.x", "NumericEquals": 1, "Next": branch_target}, + ], + "Default": dangling_default, + } + for name in names[1:]: + states[name] = {"Type": "Succeed"} + + asl = {"StartAt": choice_name, "States": states} + return asl, dangling_default + + +@st.composite +def asl_with_dangling_choice_branch(draw: st.DrawFn) -> tuple[dict, str]: + """Strategy to generate an ASL with a Choice state that has a dangling branch target. + + Returns the ASL and the dangling branch target name. + """ + num_states = draw(st.integers(min_value=2, max_value=6)) + names = draw( + st.lists( + state_name_strategy, + min_size=num_states, + max_size=num_states, + unique=True, + ) + ) + dangling_branch = draw(state_name_strategy) + assume(dangling_branch not in names) + + choice_name = names[0] + valid_default = names[1] + + states = {} + states[choice_name] = { + "Type": "Choice", + "Choices": [ + {"Variable": "$.x", "NumericEquals": 1, "Next": dangling_branch}, + ], + "Default": valid_default, + } + for name in names[1:]: + states[name] = {"Type": "Succeed"} + + asl = {"StartAt": choice_name, "States": states} + return asl, dangling_branch + + +# --- Property Tests --- + + +class TestPropertyStateReferenceResolution: + """Property-based tests for check_state_references. + + **Validates: Requirements 4.3** + """ + + @settings(max_examples=100) + @given(asl=valid_linear_asl()) + def test_no_errors_for_valid_linear_workflow(self, asl: dict): + """When all Next references point to existing states, no errors are returned.""" + errors = check_state_references(asl) + assert errors == [], f"Expected no errors for valid ASL, got: {errors}" + + @settings(max_examples=100) + @given(asl=valid_choice_asl()) + def test_no_errors_for_valid_choice_workflow(self, asl: dict): + """When all Choice branch targets and Default point to existing states, no errors.""" + errors = check_state_references(asl) + assert errors == [], f"Expected no errors for valid Choice ASL, got: {errors}" + + @settings(max_examples=100) + @given(data=asl_with_dangling_next()) + def test_errors_for_dangling_next_targets(self, data: tuple[dict, set[str]]): + """Every Next target not in the States map produces an error.""" + asl, expected_dangling = data + errors = check_state_references(asl) + + # Each dangling target must be mentioned in at least one error + for dangling_name in expected_dangling: + matching_errors = [e for e in errors if dangling_name in e] + assert len(matching_errors) >= 1, ( + f"Expected error for dangling target '{dangling_name}' but found none " + f"in errors: {errors}" + ) + + @settings(max_examples=100) + @given(data=asl_with_dangling_default()) + def test_errors_for_dangling_default_target(self, data: tuple[dict, str]): + """A Default target not in the States map produces an error.""" + asl, dangling_default = data + errors = check_state_references(asl) + + matching_errors = [e for e in errors if dangling_default in e] + assert len(matching_errors) >= 1, ( + f"Expected error for dangling Default target '{dangling_default}' " + f"but found none in errors: {errors}" + ) + + @settings(max_examples=100) + @given(data=asl_with_dangling_choice_branch()) + def test_errors_for_dangling_choice_branch_target(self, data: tuple[dict, str]): + """A Choice branch Next target not in the States map produces an error.""" + asl, dangling_branch = data + errors = check_state_references(asl) + + matching_errors = [e for e in errors if dangling_branch in e] + assert len(matching_errors) >= 1, ( + f"Expected error for dangling Choice branch target '{dangling_branch}' " + f"but found none in errors: {errors}" + ) + + @settings(max_examples=100) + @given(asl=valid_linear_asl()) + def test_no_false_positives_for_valid_states(self, asl: dict): + """Valid state names never appear as targets in error messages.""" + errors = check_state_references(asl) + # If there are any errors, they should not reference valid states as dangling + defined_states = set(asl.get("States", {}).keys()) + for error in errors: + # No valid state should be reported as undefined + for state_name in defined_states: + assert f"undefined state '{state_name}'" not in error diff --git a/packages/orcabus-pipeline-test-utils/tests/test_property_untested_lambda_warning.py b/packages/orcabus-pipeline-test-utils/tests/test_property_untested_lambda_warning.py new file mode 100644 index 0000000..c125b50 --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/tests/test_property_untested_lambda_warning.py @@ -0,0 +1,283 @@ +"""Property-based tests for untested Lambda warning completeness. + +# Feature: deployment-integration-tests, Property 1: Untested Lambda Warning Completeness + +Validates: Requirements 1.7 + +For any set of Lambda module directories and corresponding test files, the warning +output SHALL list exactly those Lambda modules that have no matching +`test_{module_name}.py` file, and no others. +""" + +from __future__ import annotations + +from hypothesis import given, settings +from hypothesis import strategies as st + + +# --- Core Logic Under Test --- + +# Extracted from the reference service conftest.py at +# service-dragen-wgts-dna-pipeline-manager/conftest.py +# This is the pure-function equivalent of the untested Lambda detection logic. + +EXCLUDED_DIRS = {"tests", "__pycache__"} + + +def find_lambda_modules(directory_names: list[str]) -> list[str]: + """Find Lambda module directory names from a list of directory names. + + A Lambda module directory ends in '_py' and is not in the excluded set. + Returns sorted list of matching directory names. + """ + return sorted( + name + for name in directory_names + if name.endswith("_py") and name not in EXCLUDED_DIRS + ) + + +def find_untested_modules( + lambda_modules: list[str], + test_file_names: set[str], +) -> list[str]: + """Determine which Lambda modules have no corresponding test file. + + Convention: directory `foo_bar_py` -> expected test file `test_foo_bar.py` + (strip the `_py` suffix to get module_name, then expect `test_{module_name}.py`) + + Args: + lambda_modules: List of Lambda module directory names (ending in _py). + test_file_names: Set of test file names present in the tests directory. + + Returns: + List of Lambda module directory names that have no matching test file. + """ + untested = [] + for module_dir_name in lambda_modules: + module_name = module_dir_name.removesuffix("_py") + expected_test_file = f"test_{module_name}.py" + if expected_test_file not in test_file_names: + untested.append(module_dir_name) + return untested + + +# --- Strategies --- + +# Lambda module names must end in _py, be non-empty before the suffix, +# and use valid filesystem characters (snake_case convention). +lambda_module_name_strategy = st.from_regex( + r"[a-z][a-z0-9_]{0,29}_py", + fullmatch=True, +).filter(lambda s: s not in EXCLUDED_DIRS) + +# Non-lambda directory names (don't end in _py, or are excluded) +non_lambda_dir_strategy = st.one_of( + # Directories that don't end in _py + st.from_regex(r"[a-z][a-z0-9_]{0,20}", fullmatch=True).filter( + lambda s: not s.endswith("_py") + ), + # Excluded directories + st.sampled_from(["tests", "__pycache__"]), +) + + +@st.composite +def lambda_modules_and_test_files(draw: st.DrawFn) -> tuple[list[str], set[str], set[str]]: + """Generate a set of Lambda modules, some with test files, some without. + + Returns: + (all_directory_names, test_file_names, expected_untested_module_names) + """ + # Generate Lambda module directories + num_modules = draw(st.integers(min_value=0, max_value=15)) + lambda_modules = draw( + st.lists( + lambda_module_name_strategy, + min_size=num_modules, + max_size=num_modules, + unique=True, + ) + ) + + # Generate some non-lambda directories to mix in + num_non_lambda = draw(st.integers(min_value=0, max_value=5)) + non_lambda_dirs = draw( + st.lists( + non_lambda_dir_strategy, + min_size=num_non_lambda, + max_size=num_non_lambda, + ) + ) + + # Decide which Lambda modules are tested (have a matching test file) + tested_mask = draw( + st.lists( + st.booleans(), + min_size=len(lambda_modules), + max_size=len(lambda_modules), + ) + ) + + # Build test file names for tested modules + test_file_names: set[str] = set() + expected_untested: set[str] = set() + for module_dir_name, is_tested in zip(lambda_modules, tested_mask): + module_name = module_dir_name.removesuffix("_py") + if is_tested: + test_file_names.add(f"test_{module_name}.py") + else: + expected_untested.add(module_dir_name) + + # Add some spurious test files that don't correspond to any module + num_spurious = draw(st.integers(min_value=0, max_value=3)) + spurious_test_files = draw( + st.lists( + st.from_regex(r"test_[a-z][a-z0-9_]{0,20}\.py", fullmatch=True), + min_size=num_spurious, + max_size=num_spurious, + ) + ) + # Only add spurious files that don't accidentally match a real module + real_test_names = { + f"test_{m.removesuffix('_py')}.py" for m in lambda_modules + } + for f in spurious_test_files: + if f not in real_test_names: + test_file_names.add(f) + + all_dirs = lambda_modules + non_lambda_dirs + return all_dirs, test_file_names, expected_untested + + +# --- Property Tests --- + + +class TestPropertyUntestedLambdaWarningCompleteness: + """Property-based tests for untested Lambda warning detection. + + **Validates: Requirements 1.7** + """ + + @settings(max_examples=100) + @given(data=lambda_modules_and_test_files()) + def test_untested_modules_are_exactly_those_without_test_files( + self, data: tuple[list[str], set[str], set[str]] + ): + """The untested list contains exactly those modules with no matching test file, + and no others.""" + all_dirs, test_file_names, expected_untested = data + + # Step 1: Extract Lambda modules from all directories + lambda_modules = find_lambda_modules(all_dirs) + + # Step 2: Determine untested modules + untested = find_untested_modules(lambda_modules, test_file_names) + + # Assert: untested set matches expected + assert set(untested) == expected_untested, ( + f"Expected untested={expected_untested}, got={set(untested)}.\n" + f"Lambda modules={lambda_modules}, test_files={test_file_names}" + ) + + @settings(max_examples=100) + @given(data=lambda_modules_and_test_files()) + def test_untested_modules_is_subset_of_lambda_modules( + self, data: tuple[list[str], set[str], set[str]] + ): + """Every module in the untested list is a valid Lambda module.""" + all_dirs, test_file_names, _ = data + + lambda_modules = find_lambda_modules(all_dirs) + untested = find_untested_modules(lambda_modules, test_file_names) + + # All untested modules must be in the Lambda modules list + assert set(untested).issubset(set(lambda_modules)), ( + f"Untested modules {set(untested)} not a subset of " + f"lambda modules {set(lambda_modules)}" + ) + + @settings(max_examples=100) + @given(data=lambda_modules_and_test_files()) + def test_tested_modules_not_in_untested_list( + self, data: tuple[list[str], set[str], set[str]] + ): + """Modules that have a corresponding test file never appear in the untested list.""" + all_dirs, test_file_names, _ = data + + lambda_modules = find_lambda_modules(all_dirs) + untested = find_untested_modules(lambda_modules, test_file_names) + + # Verify no tested module appears in untested + for module_dir_name in lambda_modules: + module_name = module_dir_name.removesuffix("_py") + expected_test = f"test_{module_name}.py" + if expected_test in test_file_names: + assert module_dir_name not in untested, ( + f"Module '{module_dir_name}' has test file '{expected_test}' " + f"but appears in untested list" + ) + + @settings(max_examples=100) + @given( + lambda_modules=st.lists( + lambda_module_name_strategy, + min_size=1, + max_size=10, + unique=True, + ) + ) + def test_all_modules_untested_when_no_test_files(self, lambda_modules: list[str]): + """When no test files exist, all Lambda modules are reported as untested.""" + sorted_modules = find_lambda_modules(lambda_modules) + untested = find_untested_modules(sorted_modules, set()) + + assert set(untested) == set(sorted_modules), ( + f"Expected all modules untested with empty test set, " + f"got untested={set(untested)}, modules={set(sorted_modules)}" + ) + + @settings(max_examples=100) + @given( + lambda_modules=st.lists( + lambda_module_name_strategy, + min_size=1, + max_size=10, + unique=True, + ) + ) + def test_no_modules_untested_when_all_have_test_files(self, lambda_modules: list[str]): + """When every Lambda module has a corresponding test file, untested list is empty.""" + sorted_modules = find_lambda_modules(lambda_modules) + + # Create test files for all modules + test_file_names = { + f"test_{m.removesuffix('_py')}.py" for m in sorted_modules + } + + untested = find_untested_modules(sorted_modules, test_file_names) + + assert untested == [], ( + f"Expected no untested modules when all have tests, " + f"got untested={untested}" + ) + + @settings(max_examples=100) + @given( + dirs=st.lists( + non_lambda_dir_strategy, + min_size=0, + max_size=10, + ) + ) + def test_excluded_and_non_py_dirs_never_appear(self, dirs: list[str]): + """Directories not ending in _py or in the excluded set are never Lambda modules.""" + lambda_modules = find_lambda_modules(dirs) + + for module in lambda_modules: + assert module.endswith("_py"), ( + f"Module '{module}' doesn't end with '_py'" + ) + assert module not in EXCLUDED_DIRS, ( + f"Module '{module}' is in excluded set" + ) diff --git a/packages/orcabus-pipeline-test-utils/tests/test_reference_checker.py b/packages/orcabus-pipeline-test-utils/tests/test_reference_checker.py new file mode 100644 index 0000000..72086a3 --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/tests/test_reference_checker.py @@ -0,0 +1,343 @@ +"""Unit tests for the state reference checker module.""" + +from orcabus_pipeline_test_utils.asl_validation.reference_checker import ( + check_state_references, +) + + +class TestCheckStateReferences: + """Tests for the check_state_references function.""" + + def test_valid_linear_workflow(self): + """A linear workflow with valid Next references should produce no errors.""" + asl = { + "StartAt": "Step1", + "States": { + "Step1": {"Type": "Task", "Next": "Step2"}, + "Step2": {"Type": "Task", "Next": "Step3"}, + "Step3": {"Type": "Succeed"}, + }, + } + errors = check_state_references(asl) + assert errors == [] + + def test_dangling_next_reference(self): + """A 'Next' field referencing a non-existent state should produce an error.""" + asl = { + "StartAt": "Step1", + "States": { + "Step1": {"Type": "Task", "Next": "NonExistent"}, + "Step2": {"Type": "Succeed"}, + }, + } + errors = check_state_references(asl) + assert len(errors) == 1 + assert "NonExistent" in errors[0] + assert "Step1" in errors[0] + + def test_dangling_start_at(self): + """A 'StartAt' field referencing a non-existent state should produce an error.""" + asl = { + "StartAt": "MissingState", + "States": { + "Step1": {"Type": "Succeed"}, + }, + } + errors = check_state_references(asl) + assert len(errors) == 1 + assert "MissingState" in errors[0] + assert "StartAt" in errors[0] + + def test_valid_choice_state(self): + """A Choice state with valid branch targets and Default should produce no errors.""" + asl = { + "StartAt": "ChoiceState", + "States": { + "ChoiceState": { + "Type": "Choice", + "Choices": [ + {"Variable": "$.x", "NumericEquals": 1, "Next": "BranchA"}, + {"Variable": "$.x", "NumericEquals": 2, "Next": "BranchB"}, + ], + "Default": "FallbackState", + }, + "BranchA": {"Type": "Succeed"}, + "BranchB": {"Type": "Succeed"}, + "FallbackState": {"Type": "Succeed"}, + }, + } + errors = check_state_references(asl) + assert errors == [] + + def test_dangling_choice_branch_target(self): + """A Choice rule with a non-existent Next target should produce an error.""" + asl = { + "StartAt": "ChoiceState", + "States": { + "ChoiceState": { + "Type": "Choice", + "Choices": [ + {"Variable": "$.x", "NumericEquals": 1, "Next": "MissingBranch"}, + ], + "Default": "FallbackState", + }, + "FallbackState": {"Type": "Succeed"}, + }, + } + errors = check_state_references(asl) + assert len(errors) == 1 + assert "MissingBranch" in errors[0] + assert "Choice rule [0]" in errors[0] + + def test_dangling_default_reference(self): + """A 'Default' field referencing a non-existent state should produce an error.""" + asl = { + "StartAt": "ChoiceState", + "States": { + "ChoiceState": { + "Type": "Choice", + "Choices": [ + {"Variable": "$.x", "NumericEquals": 1, "Next": "Step1"}, + ], + "Default": "NonExistentDefault", + }, + "Step1": {"Type": "Succeed"}, + }, + } + errors = check_state_references(asl) + assert len(errors) == 1 + assert "NonExistentDefault" in errors[0] + assert "Default" in errors[0] + + def test_valid_catch_references(self): + """Catch blocks with valid Next targets should produce no errors.""" + asl = { + "StartAt": "TaskState", + "States": { + "TaskState": { + "Type": "Task", + "Resource": "arn:aws:lambda:us-east-1:123:function:fn", + "Next": "SuccessState", + "Catch": [ + {"ErrorEquals": ["States.ALL"], "Next": "ErrorState"}, + ], + }, + "SuccessState": {"Type": "Succeed"}, + "ErrorState": {"Type": "Fail"}, + }, + } + errors = check_state_references(asl) + assert errors == [] + + def test_dangling_catch_reference(self): + """A Catch block with a non-existent Next target should produce an error.""" + asl = { + "StartAt": "TaskState", + "States": { + "TaskState": { + "Type": "Task", + "Resource": "arn:aws:lambda:us-east-1:123:function:fn", + "Next": "SuccessState", + "Catch": [ + {"ErrorEquals": ["States.ALL"], "Next": "MissingError"}, + ], + }, + "SuccessState": {"Type": "Succeed"}, + }, + } + errors = check_state_references(asl) + assert len(errors) == 1 + assert "MissingError" in errors[0] + assert "Catch [0]" in errors[0] + + def test_parallel_state_with_valid_branches(self): + """A Parallel state with internally-valid branches should produce no errors.""" + asl = { + "StartAt": "ParallelState", + "States": { + "ParallelState": { + "Type": "Parallel", + "Branches": [ + { + "StartAt": "BranchStep1", + "States": { + "BranchStep1": {"Type": "Task", "Next": "BranchStep2"}, + "BranchStep2": {"Type": "Succeed"}, + }, + }, + ], + "Next": "Done", + }, + "Done": {"Type": "Succeed"}, + }, + } + errors = check_state_references(asl) + assert errors == [] + + def test_parallel_state_with_dangling_branch_reference(self): + """A Parallel branch with an invalid internal reference should produce an error.""" + asl = { + "StartAt": "ParallelState", + "States": { + "ParallelState": { + "Type": "Parallel", + "Branches": [ + { + "StartAt": "BranchStep1", + "States": { + "BranchStep1": {"Type": "Task", "Next": "Missing"}, + }, + }, + ], + "Next": "Done", + }, + "Done": {"Type": "Succeed"}, + }, + } + errors = check_state_references(asl) + assert len(errors) == 1 + assert "Missing" in errors[0] + + def test_parallel_branch_dangling_start_at(self): + """A Parallel branch with an invalid StartAt should produce an error.""" + asl = { + "StartAt": "ParallelState", + "States": { + "ParallelState": { + "Type": "Parallel", + "Branches": [ + { + "StartAt": "NoSuchState", + "States": { + "BranchStep1": {"Type": "Succeed"}, + }, + }, + ], + "Next": "Done", + }, + "Done": {"Type": "Succeed"}, + }, + } + errors = check_state_references(asl) + assert len(errors) == 1 + assert "NoSuchState" in errors[0] + assert "StartAt" in errors[0] + + def test_map_state_with_valid_iterator(self): + """A Map state with a valid Iterator should produce no errors.""" + asl = { + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "Iterator": { + "StartAt": "MapStep1", + "States": { + "MapStep1": {"Type": "Task", "Next": "MapStep2"}, + "MapStep2": {"Type": "Succeed"}, + }, + }, + "Next": "Done", + }, + "Done": {"Type": "Succeed"}, + }, + } + errors = check_state_references(asl) + assert errors == [] + + def test_map_state_with_item_processor(self): + """A Map state using ItemProcessor (newer ASL) should be validated.""" + asl = { + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "ItemProcessor": { + "StartAt": "ProcessItem", + "States": { + "ProcessItem": {"Type": "Task", "Next": "GhostState"}, + }, + }, + "Next": "Done", + }, + "Done": {"Type": "Succeed"}, + }, + } + errors = check_state_references(asl) + assert len(errors) == 1 + assert "GhostState" in errors[0] + + def test_multiple_errors_reported(self): + """Multiple dangling references should all be reported.""" + asl = { + "StartAt": "Step1", + "States": { + "Step1": {"Type": "Task", "Next": "Missing1"}, + "Step2": { + "Type": "Choice", + "Choices": [ + {"Variable": "$.x", "NumericEquals": 1, "Next": "Missing2"}, + ], + "Default": "Missing3", + }, + }, + } + errors = check_state_references(asl) + assert len(errors) == 3 + dangling_names = " ".join(errors) + assert "Missing1" in dangling_names + assert "Missing2" in dangling_names + assert "Missing3" in dangling_names + + def test_empty_states_map(self): + """An ASL with an empty States map and dangling StartAt should produce an error.""" + asl = { + "StartAt": "Something", + "States": {}, + } + errors = check_state_references(asl) + assert len(errors) == 1 + assert "StartAt" in errors[0] + + def test_no_states_key(self): + """An ASL without a 'States' key should not crash (returns error about States).""" + asl = {"StartAt": "Something"} + errors = check_state_references(asl) + # Should not crash — States defaults to empty dict + # StartAt will be dangling + assert len(errors) == 1 + assert "StartAt" in errors[0] + + def test_terminal_states_without_next(self): + """Succeed and Fail states without Next should not produce errors.""" + asl = { + "StartAt": "Step1", + "States": { + "Step1": {"Type": "Task", "Next": "EndSuccess"}, + "EndSuccess": {"Type": "Succeed"}, + "EndFail": {"Type": "Fail", "Error": "Oops", "Cause": "Something"}, + }, + } + errors = check_state_references(asl) + assert errors == [] + + def test_end_true_without_next(self): + """A state with 'End': true and no 'Next' should not produce errors.""" + asl = { + "StartAt": "Step1", + "States": { + "Step1": {"Type": "Task", "End": True}, + }, + } + errors = check_state_references(asl) + assert errors == [] + + def test_states_field_not_dict(self): + """If 'States' is not a dict, should return an error.""" + asl = { + "StartAt": "Step1", + "States": "invalid", + } + errors = check_state_references(asl) + assert len(errors) == 1 + assert "not a valid object" in errors[0] diff --git a/packages/orcabus-pipeline-test-utils/tests/test_smoke_lambda.py b/packages/orcabus-pipeline-test-utils/tests/test_smoke_lambda.py new file mode 100644 index 0000000..a7ee1c3 --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/tests/test_smoke_lambda.py @@ -0,0 +1,194 @@ +"""Unit tests for the Lambda DryRun smoke check.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import boto3 +from botocore.stub import Stubber + +from orcabus_pipeline_test_utils.smoke import SmokeTestResult +from orcabus_pipeline_test_utils.smoke.lambda_check import check_lambda_invocable + + +class TestCheckLambdaInvocable: + """Tests for check_lambda_invocable function.""" + + def test_returns_passed_on_http_204(self) -> None: + """DryRun returning HTTP 204 should produce passed=True.""" + session = boto3.Session(region_name="ap-southeast-2") + client = session.client("lambda") + + with Stubber(client) as stubber: + stubber.add_response( + "invoke", + {"StatusCode": 204}, + expected_params={ + "FunctionName": "my-function", + "InvocationType": "DryRun", + }, + ) + + with patch.object(session, "client", return_value=client): + result = check_lambda_invocable("my-function", session) + + assert result == SmokeTestResult( + resource_name="my-function", + resource_type="lambda", + passed=True, + error_type=None, + error_message=None, + ) + + def test_returns_failed_on_non_204_status(self) -> None: + """Non-204 status code should produce passed=False with error_type='config'.""" + session = boto3.Session(region_name="ap-southeast-2") + client = session.client("lambda") + + with Stubber(client) as stubber: + stubber.add_response( + "invoke", + {"StatusCode": 403}, + expected_params={ + "FunctionName": "my-function", + "InvocationType": "DryRun", + }, + ) + + with patch.object(session, "client", return_value=client): + result = check_lambda_invocable("my-function", session) + + assert result.passed is False + assert result.error_type == "config" + assert result.resource_name == "my-function" + assert result.resource_type == "lambda" + assert "403" in result.error_message + + def test_access_denied_classified_as_auth(self) -> None: + """AccessDeniedException should be classified as error_type='auth'.""" + session = boto3.Session(region_name="ap-southeast-2") + client = session.client("lambda") + + with Stubber(client) as stubber: + stubber.add_client_error( + "invoke", + service_error_code="AccessDeniedException", + service_message="User is not authorized to perform lambda:InvokeFunction", + expected_params={ + "FunctionName": "my-function", + "InvocationType": "DryRun", + }, + ) + + with patch.object(session, "client", return_value=client): + result = check_lambda_invocable("my-function", session) + + assert result.passed is False + assert result.error_type == "auth" + assert "AccessDeniedException" in result.error_message + + def test_expired_token_classified_as_auth(self) -> None: + """ExpiredTokenException should be classified as error_type='auth'.""" + session = boto3.Session(region_name="ap-southeast-2") + client = session.client("lambda") + + with Stubber(client) as stubber: + stubber.add_client_error( + "invoke", + service_error_code="ExpiredTokenException", + service_message="The security token included in the request is expired", + expected_params={ + "FunctionName": "my-function", + "InvocationType": "DryRun", + }, + ) + + with patch.object(session, "client", return_value=client): + result = check_lambda_invocable("my-function", session) + + assert result.passed is False + assert result.error_type == "auth" + assert "ExpiredTokenException" in result.error_message + + def test_resource_not_found_classified_as_config(self) -> None: + """ResourceNotFoundException should be classified as error_type='config'.""" + session = boto3.Session(region_name="ap-southeast-2") + client = session.client("lambda") + + with Stubber(client) as stubber: + stubber.add_client_error( + "invoke", + service_error_code="ResourceNotFoundException", + service_message="Function not found: arn:aws:lambda:ap-southeast-2:123456789012:function:my-function", + expected_params={ + "FunctionName": "my-function", + "InvocationType": "DryRun", + }, + ) + + with patch.object(session, "client", return_value=client): + result = check_lambda_invocable("my-function", session) + + assert result.passed is False + assert result.error_type == "config" + assert "ResourceNotFoundException" in result.error_message + + def test_generic_exception_classified_as_config(self) -> None: + """Non-ClientError exceptions should be classified as error_type='config'.""" + session = boto3.Session(region_name="ap-southeast-2") + mock_client = MagicMock() + mock_client.invoke.side_effect = ConnectionError("Network timeout") + + with patch.object(session, "client", return_value=mock_client): + result = check_lambda_invocable("my-function", session) + + assert result.passed is False + assert result.error_type == "config" + assert "Network timeout" in result.error_message + + def test_result_includes_function_name(self) -> None: + """The result should always include the function name as resource_name.""" + session = boto3.Session(region_name="ap-southeast-2") + client = session.client("lambda") + + with Stubber(client) as stubber: + stubber.add_response( + "invoke", + {"StatusCode": 204}, + expected_params={ + "FunctionName": "arn:aws:lambda:ap-southeast-2:123456789012:function:test-fn", + "InvocationType": "DryRun", + }, + ) + + with patch.object(session, "client", return_value=client): + result = check_lambda_invocable( + "arn:aws:lambda:ap-southeast-2:123456789012:function:test-fn", + session, + ) + + assert result.resource_name == "arn:aws:lambda:ap-southeast-2:123456789012:function:test-fn" + assert result.resource_type == "lambda" + + def test_invalid_parameter_classified_as_config(self) -> None: + """InvalidParameterValueException should be classified as error_type='config'.""" + session = boto3.Session(region_name="ap-southeast-2") + client = session.client("lambda") + + with Stubber(client) as stubber: + stubber.add_client_error( + "invoke", + service_error_code="InvalidParameterValueException", + service_message="Invalid function name", + expected_params={ + "FunctionName": "bad-function-config", + "InvocationType": "DryRun", + }, + ) + + with patch.object(session, "client", return_value=client): + result = check_lambda_invocable("bad-function-config", session) + + assert result.passed is False + assert result.error_type == "config" + assert "InvalidParameterValueException" in result.error_message diff --git a/packages/orcabus-pipeline-test-utils/tests/test_smoke_sfn.py b/packages/orcabus-pipeline-test-utils/tests/test_smoke_sfn.py new file mode 100644 index 0000000..2e4deda --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/tests/test_smoke_sfn.py @@ -0,0 +1,202 @@ +"""Unit tests for smoke/sfn_check.py — State Machine checker.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from botocore.exceptions import ClientError + +from orcabus_pipeline_test_utils.smoke import SmokeTestResult +from orcabus_pipeline_test_utils.smoke.sfn_check import check_state_machine_active + + +_SM_ARN = "arn:aws:states:ap-southeast-2:123456789012:stateMachine:my-pipeline-sfn" + + +@pytest.fixture +def mock_session() -> MagicMock: + """Create a mock boto3 Session.""" + return MagicMock() + + +@pytest.fixture +def mock_sfn_client(mock_session: MagicMock) -> MagicMock: + """Create a mock stepfunctions client wired to the session.""" + client = MagicMock() + mock_session.client.return_value = client + return client + + +class TestCheckStateMachineActiveSuccess: + """Tests for the happy path — ACTIVE state machine with definition.""" + + def test_returns_passed_when_active_with_definition( + self, mock_session: MagicMock, mock_sfn_client: MagicMock + ) -> None: + mock_sfn_client.describe_state_machine.return_value = { + "stateMachineArn": _SM_ARN, + "status": "ACTIVE", + "definition": '{"StartAt": "Start", "States": {"Start": {"Type": "Pass", "End": true}}}', + "name": "my-pipeline-sfn", + } + + result = check_state_machine_active(_SM_ARN, mock_session) + + assert result.passed is True + assert result.resource_name == _SM_ARN + assert result.resource_type == "state_machine" + assert result.error_type is None + assert result.error_message is None + + def test_calls_describe_state_machine_with_correct_arn( + self, mock_session: MagicMock, mock_sfn_client: MagicMock + ) -> None: + mock_sfn_client.describe_state_machine.return_value = { + "status": "ACTIVE", + "definition": "{}", + } + + check_state_machine_active(_SM_ARN, mock_session) + + mock_session.client.assert_called_once_with("stepfunctions") + mock_sfn_client.describe_state_machine.assert_called_once_with( + stateMachineArn=_SM_ARN + ) + + +class TestCheckStateMachineActiveStatusFailures: + """Tests for non-ACTIVE status conditions.""" + + def test_fails_when_status_is_deleting( + self, mock_session: MagicMock, mock_sfn_client: MagicMock + ) -> None: + mock_sfn_client.describe_state_machine.return_value = { + "status": "DELETING", + "definition": '{"StartAt": "X", "States": {}}', + } + + result = check_state_machine_active(_SM_ARN, mock_session) + + assert result.passed is False + assert result.error_type == "config" + assert "DELETING" in result.error_message + assert "ACTIVE" in result.error_message + + def test_fails_when_definition_is_none( + self, mock_session: MagicMock, mock_sfn_client: MagicMock + ) -> None: + mock_sfn_client.describe_state_machine.return_value = { + "status": "ACTIVE", + "definition": None, + } + + result = check_state_machine_active(_SM_ARN, mock_session) + + assert result.passed is False + assert result.error_type == "config" + assert "null or empty" in result.error_message + + def test_fails_when_definition_is_empty_string( + self, mock_session: MagicMock, mock_sfn_client: MagicMock + ) -> None: + mock_sfn_client.describe_state_machine.return_value = { + "status": "ACTIVE", + "definition": "", + } + + result = check_state_machine_active(_SM_ARN, mock_session) + + assert result.passed is False + assert result.error_type == "config" + assert "null or empty" in result.error_message + + +class TestCheckStateMachineActiveAuthErrors: + """Tests for authentication/authorization errors.""" + + def test_access_denied_returns_auth_error( + self, mock_session: MagicMock, mock_sfn_client: MagicMock + ) -> None: + mock_sfn_client.describe_state_machine.side_effect = ClientError( + error_response={ + "Error": { + "Code": "AccessDeniedException", + "Message": "User is not authorized to perform this action", + } + }, + operation_name="DescribeStateMachine", + ) + + result = check_state_machine_active(_SM_ARN, mock_session) + + assert result.passed is False + assert result.error_type == "auth" + assert "AccessDeniedException" in result.error_message + + def test_expired_token_returns_auth_error( + self, mock_session: MagicMock, mock_sfn_client: MagicMock + ) -> None: + mock_sfn_client.describe_state_machine.side_effect = ClientError( + error_response={ + "Error": { + "Code": "ExpiredTokenException", + "Message": "The security token has expired", + } + }, + operation_name="DescribeStateMachine", + ) + + result = check_state_machine_active(_SM_ARN, mock_session) + + assert result.passed is False + assert result.error_type == "auth" + assert "ExpiredTokenException" in result.error_message + + +class TestCheckStateMachineActiveConfigErrors: + """Tests for non-auth ClientErrors and generic exceptions.""" + + def test_resource_not_found_returns_config_error( + self, mock_session: MagicMock, mock_sfn_client: MagicMock + ) -> None: + mock_sfn_client.describe_state_machine.side_effect = ClientError( + error_response={ + "Error": { + "Code": "StateMachineDoesNotExist", + "Message": "State machine does not exist", + } + }, + operation_name="DescribeStateMachine", + ) + + result = check_state_machine_active(_SM_ARN, mock_session) + + assert result.passed is False + assert result.error_type == "config" + assert "StateMachineDoesNotExist" in result.error_message + + def test_generic_exception_returns_config_error( + self, mock_session: MagicMock, mock_sfn_client: MagicMock + ) -> None: + mock_sfn_client.describe_state_machine.side_effect = RuntimeError( + "Connection timeout" + ) + + result = check_state_machine_active(_SM_ARN, mock_session) + + assert result.passed is False + assert result.error_type == "config" + assert "Connection timeout" in result.error_message + + def test_result_is_smoke_test_result_dataclass( + self, mock_session: MagicMock, mock_sfn_client: MagicMock + ) -> None: + mock_sfn_client.describe_state_machine.return_value = { + "status": "ACTIVE", + "definition": '{"StartAt": "S", "States": {}}', + } + + result = check_state_machine_active(_SM_ARN, mock_session) + + assert isinstance(result, SmokeTestResult) diff --git a/packages/orcabus-pipeline-test-utils/tests/test_smoke_ssm.py b/packages/orcabus-pipeline-test-utils/tests/test_smoke_ssm.py new file mode 100644 index 0000000..87dfd96 --- /dev/null +++ b/packages/orcabus-pipeline-test-utils/tests/test_smoke_ssm.py @@ -0,0 +1,129 @@ +"""Unit tests for SSM parameter existence checker.""" + +from __future__ import annotations + +import boto3 +import pytest +from moto import mock_aws + +from orcabus_pipeline_test_utils.smoke import SmokeTestResult +from orcabus_pipeline_test_utils.smoke.ssm_check import check_ssm_parameters_exist + + +@pytest.fixture +def ssm_session(): + """Provide a moto-mocked boto3 session with SSM available.""" + with mock_aws(): + session = boto3.Session(region_name="ap-southeast-2") + yield session + + +@pytest.fixture +def ssm_session_with_params(ssm_session): + """Create a session with some pre-existing SSM parameters.""" + client = ssm_session.client("ssm") + client.put_parameter( + Name="/orcabus/workflows/test/param-a", + Value="value-a", + Type="String", + Overwrite=True, + ) + client.put_parameter( + Name="/orcabus/workflows/test/param-b", + Value="secret-value", + Type="SecureString", + Overwrite=True, + ) + return ssm_session + + +class TestCheckSsmParametersExist: + """Tests for check_ssm_parameters_exist function.""" + + def test_empty_parameter_list_returns_empty_results(self, ssm_session): + """Passing an empty list returns no results.""" + results = check_ssm_parameters_exist([], ssm_session) + assert results == [] + + def test_existing_parameters_return_passed(self, ssm_session_with_params): + """Parameters that exist should return passed=True.""" + results = check_ssm_parameters_exist( + ["/orcabus/workflows/test/param-a", "/orcabus/workflows/test/param-b"], + ssm_session_with_params, + ) + assert len(results) == 2 + for result in results: + assert result.passed is True + assert result.error_type is None + assert result.error_message is None + assert result.resource_type == "ssm_parameter" + + def test_nonexistent_parameter_returns_failed_config(self, ssm_session): + """A parameter that does not exist should return passed=False with error_type='config'.""" + results = check_ssm_parameters_exist( + ["/orcabus/workflows/test/nonexistent"], + ssm_session, + ) + assert len(results) == 1 + result = results[0] + assert result.passed is False + assert result.resource_name == "/orcabus/workflows/test/nonexistent" + assert result.resource_type == "ssm_parameter" + assert result.error_type == "config" + assert result.error_message is not None + + def test_mix_of_existing_and_nonexistent(self, ssm_session_with_params): + """Results correctly reflect mixed existing and nonexistent parameters.""" + results = check_ssm_parameters_exist( + [ + "/orcabus/workflows/test/param-a", + "/orcabus/workflows/test/missing", + "/orcabus/workflows/test/param-b", + ], + ssm_session_with_params, + ) + assert len(results) == 3 + # First: exists + assert results[0].passed is True + assert results[0].resource_name == "/orcabus/workflows/test/param-a" + # Second: missing + assert results[1].passed is False + assert results[1].resource_name == "/orcabus/workflows/test/missing" + assert results[1].error_type == "config" + # Third: exists + assert results[2].passed is True + assert results[2].resource_name == "/orcabus/workflows/test/param-b" + + def test_resource_name_matches_parameter_path(self, ssm_session_with_params): + """The resource_name in results should match the input parameter path.""" + paths = ["/orcabus/workflows/test/param-a"] + results = check_ssm_parameters_exist(paths, ssm_session_with_params) + assert results[0].resource_name == "/orcabus/workflows/test/param-a" + + def test_secure_string_parameter_readable(self, ssm_session_with_params): + """SecureString parameters should be readable (WithDecryption=True).""" + results = check_ssm_parameters_exist( + ["/orcabus/workflows/test/param-b"], + ssm_session_with_params, + ) + assert len(results) == 1 + assert results[0].passed is True + + def test_result_is_smoke_test_result_type(self, ssm_session_with_params): + """Results should be instances of SmokeTestResult.""" + results = check_ssm_parameters_exist( + ["/orcabus/workflows/test/param-a"], + ssm_session_with_params, + ) + assert isinstance(results[0], SmokeTestResult) + + def test_one_result_per_parameter(self, ssm_session_with_params): + """The number of results should equal the number of input parameter paths.""" + paths = [ + "/orcabus/workflows/test/param-a", + "/orcabus/workflows/test/param-b", + "/orcabus/workflows/test/missing-1", + "/orcabus/workflows/test/missing-2", + ] + results = check_ssm_parameters_exist(paths, ssm_session_with_params) + assert len(results) == len(paths)