consolidation of some of the modules, localization fixes, product advisories work, qa work

This commit is contained in:
master
2026-03-05 03:54:22 +02:00
parent 7bafcc3eef
commit 8e1cb9448d
3878 changed files with 72600 additions and 46861 deletions

View File

@@ -19,7 +19,7 @@
"kind": "file",
"source": "package.json",
"locator": "package.json",
"sha256": "06c93b840f9cc3e032454ba4b5745967ecb73b0b4ced1d827f98a36d7747702a"
"sha256": "465919e1195aa0b066f473c55341df77abff6a6b7d62e25d63ccfb7c13e3287b"
}
]
},
@@ -43,7 +43,7 @@
"kind": "file",
"source": "package.json",
"locator": "package.json",
"sha256": "06c93b840f9cc3e032454ba4b5745967ecb73b0b4ced1d827f98a36d7747702a"
"sha256": "465919e1195aa0b066f473c55341df77abff6a6b7d62e25d63ccfb7c13e3287b"
}
]
},
@@ -67,7 +67,7 @@
"kind": "file",
"source": "package.json",
"locator": "package.json",
"sha256": "06c93b840f9cc3e032454ba4b5745967ecb73b0b4ced1d827f98a36d7747702a"
"sha256": "465919e1195aa0b066f473c55341df77abff6a6b7d62e25d63ccfb7c13e3287b"
}
]
}

View File

@@ -20,14 +20,14 @@
"source": "integrity",
"locator": ".layers/layer0/app/bun.lock:packages[ms@2.1.3]",
"value": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"sha256": "c04e2c61eb2caa5103dc414cbb94fb4a0e79fff444130007d54bcd2f32547dae"
"sha256": "4a384b14aba7740bd500cdf0da7329a41a2940662e9b1fcab1fbc71c6c8389e7"
},
{
"kind": "metadata",
"source": "resolved",
"locator": ".layers/layer0/app/bun.lock:packages[ms@2.1.3]",
"value": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"sha256": "c04e2c61eb2caa5103dc414cbb94fb4a0e79fff444130007d54bcd2f32547dae"
"sha256": "4a384b14aba7740bd500cdf0da7329a41a2940662e9b1fcab1fbc71c6c8389e7"
}
]
}

View File

@@ -23,14 +23,14 @@
"source": "integrity",
"locator": "bun.lock:packages[@company/internal-pkg@1.0.0]",
"value": "sha512-customhash123==",
"sha256": "dccabd071efe518efaea20482d057f2cd6295b1f4c43c1dc08642cefb2377a8d"
"sha256": "eb3bacf736d4a1b3cf9e02357afc1add9f20323916ce62cf8748c9ad9a80f195"
},
{
"kind": "metadata",
"source": "resolved",
"locator": "bun.lock:packages[@company/internal-pkg@1.0.0]",
"value": "https://npm.company.com/@company/internal-pkg/-/internal-pkg-1.0.0.tgz",
"sha256": "dccabd071efe518efaea20482d057f2cd6295b1f4c43c1dc08642cefb2377a8d"
"sha256": "eb3bacf736d4a1b3cf9e02357afc1add9f20323916ce62cf8748c9ad9a80f195"
}
]
}

View File

@@ -20,14 +20,14 @@
"source": "integrity",
"locator": "bun.lock:packages[debug@4.3.4]",
"value": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX\u002B7G/vCNNhehwxfkQ==",
"sha256": "18543ebd312e9698d27463883e5e2219d34d1b19b0fe80333c52a4b068bfe1b8"
"sha256": "33d4886c0591242ffb78b5e739c5248c81559312586d59d543d48387e4bb6a2b"
},
{
"kind": "metadata",
"source": "resolved",
"locator": "bun.lock:packages[debug@4.3.4]",
"value": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
"sha256": "18543ebd312e9698d27463883e5e2219d34d1b19b0fe80333c52a4b068bfe1b8"
"sha256": "33d4886c0591242ffb78b5e739c5248c81559312586d59d543d48387e4bb6a2b"
}
]
},
@@ -51,14 +51,14 @@
"source": "integrity",
"locator": "bun.lock:packages[ms@2.1.3]",
"value": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"sha256": "18543ebd312e9698d27463883e5e2219d34d1b19b0fe80333c52a4b068bfe1b8"
"sha256": "33d4886c0591242ffb78b5e739c5248c81559312586d59d543d48387e4bb6a2b"
},
{
"kind": "metadata",
"source": "resolved",
"locator": "bun.lock:packages[ms@2.1.3]",
"value": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"sha256": "18543ebd312e9698d27463883e5e2219d34d1b19b0fe80333c52a4b068bfe1b8"
"sha256": "33d4886c0591242ffb78b5e739c5248c81559312586d59d543d48387e4bb6a2b"
}
]
}

View File

@@ -22,7 +22,7 @@
"source": "resolved",
"locator": "bun.lock:packages[my-git-pkg@1.0.0]",
"value": "git\u002Bhttps://github.com/user/my-git-pkg.git#abc123def456",
"sha256": "214891708016d78e2960295b906bfb6db42fc2c98f2cf44bf970996c519e7c42"
"sha256": "819a7efc185bd1314d21aa7fdc0e5b2134a0c9b758ecd9daa62cb6cba2feddd0"
}
]
}

View File

@@ -19,14 +19,14 @@
"source": "integrity",
"locator": "bun.lock:packages[is-number@6.0.0]",
"value": "sha512-Wu1VZAVuL1snqOnHLxJ0l2p3pjlzLnMcJ8gJhaTZVfP7VFKN7fSJ8X/gR0qFCLwfFJ0Rqd3IxfS\u002BTY/Lc1Q7Pw==",
"sha256": "655d97c9bbccfc7380a6a217cd993129bdaec1fedf2667fc3c836a204364889c"
"sha256": "746b6c809e50ee2d7bdb27a0ee43046d48fa5f21d7597bbadd3bd44269798812"
},
{
"kind": "metadata",
"source": "resolved",
"locator": "bun.lock:packages[is-number@6.0.0]",
"value": "https://registry.npmjs.org/is-number/-/is-number-6.0.0.tgz",
"sha256": "655d97c9bbccfc7380a6a217cd993129bdaec1fedf2667fc3c836a204364889c"
"sha256": "746b6c809e50ee2d7bdb27a0ee43046d48fa5f21d7597bbadd3bd44269798812"
}
]
},
@@ -51,14 +51,14 @@
"source": "integrity",
"locator": "bun.lock:packages[is-odd@3.0.1]",
"value": "sha512-CQpnWPrDwmP1\u002BSMHXvTXAoSEu2mCPgMU0VKt1WcA7D8VXCo4HfVNlUbD1k8Tg0BVDX/LhyRaZqKqiS4vI6tTHg==",
"sha256": "655d97c9bbccfc7380a6a217cd993129bdaec1fedf2667fc3c836a204364889c"
"sha256": "746b6c809e50ee2d7bdb27a0ee43046d48fa5f21d7597bbadd3bd44269798812"
},
{
"kind": "metadata",
"source": "resolved",
"locator": "bun.lock:packages[is-odd@3.0.1]",
"value": "https://registry.npmjs.org/is-odd/-/is-odd-3.0.1.tgz",
"sha256": "655d97c9bbccfc7380a6a217cd993129bdaec1fedf2667fc3c836a204364889c"
"sha256": "746b6c809e50ee2d7bdb27a0ee43046d48fa5f21d7597bbadd3bd44269798812"
}
]
}

View File

@@ -20,14 +20,14 @@
"source": "integrity",
"locator": "bun.lock:packages[lodash@4.17.21]",
"value": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vz1kAmtILi\u002B8fm9nJMg7b0GN8sMEJz2mxG/S7mNxhWQ7\u002BD9bF8Q==",
"sha256": "7300c4967678f306370e7faff8e51450a42666ea54a4859a573e14d7de32f7d8"
"sha256": "7b34fdbdf0cb3e0d07e25f7d7f452491dcfad421138449217a1c20b2f66a6475"
},
{
"kind": "metadata",
"source": "resolved",
"locator": "bun.lock:packages[lodash@4.17.21]",
"value": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"sha256": "7300c4967678f306370e7faff8e51450a42666ea54a4859a573e14d7de32f7d8"
"sha256": "7b34fdbdf0cb3e0d07e25f7d7f452491dcfad421138449217a1c20b2f66a6475"
}
]
}

View File

@@ -19,7 +19,7 @@
"source": "resolved",
"locator": "bun.lock:packages[dev-only@1.0.0]",
"value": "https://registry.npmjs.org/dev-only/-/dev-only-1.0.0.tgz",
"sha256": "c6eb8a4235f270df8b7dcc27c35f72323101140839b8e15c6ea4e58865dd57cc"
"sha256": "4d40cc185e492e4544a6dc3b17cdfd77096e4d4260569a243eb694befbada6ac"
}
]
},
@@ -44,7 +44,7 @@
"source": "resolved",
"locator": "bun.lock:packages[dev-pkg@1.0.0]",
"value": "https://registry.npmjs.org/dev-pkg/-/dev-pkg-1.0.0.tgz",
"sha256": "c6eb8a4235f270df8b7dcc27c35f72323101140839b8e15c6ea4e58865dd57cc"
"sha256": "4d40cc185e492e4544a6dc3b17cdfd77096e4d4260569a243eb694befbada6ac"
}
]
},
@@ -68,7 +68,7 @@
"source": "resolved",
"locator": "bun.lock:packages[prod-pkg@1.0.0]",
"value": "https://registry.npmjs.org/prod-pkg/-/prod-pkg-1.0.0.tgz",
"sha256": "c6eb8a4235f270df8b7dcc27c35f72323101140839b8e15c6ea4e58865dd57cc"
"sha256": "4d40cc185e492e4544a6dc3b17cdfd77096e4d4260569a243eb694befbada6ac"
}
]
},
@@ -91,7 +91,7 @@
"source": "resolved",
"locator": "bun.lock:packages[shared@1.0.0]",
"value": "https://registry.npmjs.org/shared/-/shared-1.0.0.tgz",
"sha256": "c6eb8a4235f270df8b7dcc27c35f72323101140839b8e15c6ea4e58865dd57cc"
"sha256": "4d40cc185e492e4544a6dc3b17cdfd77096e4d4260569a243eb694befbada6ac"
}
]
}

View File

@@ -20,14 +20,14 @@
"source": "integrity",
"locator": "bun.lock:packages[ms@2.1.3]",
"value": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"sha256": "c04e2c61eb2caa5103dc414cbb94fb4a0e79fff444130007d54bcd2f32547dae"
"sha256": "4a384b14aba7740bd500cdf0da7329a41a2940662e9b1fcab1fbc71c6c8389e7"
},
{
"kind": "metadata",
"source": "resolved",
"locator": "bun.lock:packages[ms@2.1.3]",
"value": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"sha256": "c04e2c61eb2caa5103dc414cbb94fb4a0e79fff444130007d54bcd2f32547dae"
"sha256": "4a384b14aba7740bd500cdf0da7329a41a2940662e9b1fcab1fbc71c6c8389e7"
}
]
}

View File

@@ -20,14 +20,14 @@
"source": "integrity",
"locator": "bun.lock:packages[lodash@4.17.21]",
"value": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vz1kAmtILi\u002B8fm9nJMg7b0GN8sMEJz2mxG/S7mNxhWQ7\u002BD9bF8Q==",
"sha256": "6fad4629ef109a5bb788e8c4ad89fd5c32aec20302147091c3c12d46b85b6a10"
"sha256": "8a0d37c3761b81514ee397c3836ccff48167ce6aa1afdfd484ca7679e586df4a"
},
{
"kind": "metadata",
"source": "resolved",
"locator": "bun.lock:packages[lodash@4.17.21]",
"value": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"sha256": "6fad4629ef109a5bb788e8c4ad89fd5c32aec20302147091c3c12d46b85b6a10"
"sha256": "8a0d37c3761b81514ee397c3836ccff48167ce6aa1afdfd484ca7679e586df4a"
}
]
},
@@ -52,14 +52,14 @@
"source": "integrity",
"locator": "bun.lock:packages[ms@2.1.3]",
"value": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"sha256": "6fad4629ef109a5bb788e8c4ad89fd5c32aec20302147091c3c12d46b85b6a10"
"sha256": "8a0d37c3761b81514ee397c3836ccff48167ce6aa1afdfd484ca7679e586df4a"
},
{
"kind": "metadata",
"source": "resolved",
"locator": "bun.lock:packages[ms@2.1.3]",
"value": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"sha256": "6fad4629ef109a5bb788e8c4ad89fd5c32aec20302147091c3c12d46b85b6a10"
"sha256": "8a0d37c3761b81514ee397c3836ccff48167ce6aa1afdfd484ca7679e586df4a"
}
]
}

View File

@@ -21,7 +21,7 @@
"source": "resolved",
"locator": "bun.lock:packages[file-pkg@file:../file-pkg.tgz]",
"value": "file:../file-pkg.tgz",
"sha256": "116d434e799d69c9cb3dec4cbb40ae56d0d6e5a126b34ee95d9eb0b0c7970bae"
"sha256": "d7ae02476b6737ea3056226ea69e36bacb664feacd7a5223bc66ea287757656b"
}
]
},
@@ -47,7 +47,7 @@
"source": "resolved",
"locator": "bun.lock:packages[link-pkg@link:../link-pkg]",
"value": "link:../link-pkg",
"sha256": "116d434e799d69c9cb3dec4cbb40ae56d0d6e5a126b34ee95d9eb0b0c7970bae"
"sha256": "d7ae02476b6737ea3056226ea69e36bacb664feacd7a5223bc66ea287757656b"
}
]
},
@@ -73,7 +73,7 @@
"source": "resolved",
"locator": "bun.lock:packages[local-pkg@workspace:*]",
"value": "workspace:packages/local-pkg",
"sha256": "116d434e799d69c9cb3dec4cbb40ae56d0d6e5a126b34ee95d9eb0b0c7970bae"
"sha256": "d7ae02476b6737ea3056226ea69e36bacb664feacd7a5223bc66ea287757656b"
}
]
}

View File

@@ -23,14 +23,14 @@
"source": "integrity",
"locator": "bun.lock:packages[lodash@4.17.20]",
"value": "sha512-lodash-420",
"sha256": "b74a731eebc295f96d138d8f46d00893d3d352405ca422aa097c04ff5d5b40a6"
"sha256": "e83cd6aa810c1a8af47d6ae0eb621a8a5dc13b23ec08925ad9b5ff4d035cfc7c"
},
{
"kind": "metadata",
"source": "resolved",
"locator": "bun.lock:packages[lodash@4.17.20]",
"value": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz",
"sha256": "b74a731eebc295f96d138d8f46d00893d3d352405ca422aa097c04ff5d5b40a6"
"sha256": "e83cd6aa810c1a8af47d6ae0eb621a8a5dc13b23ec08925ad9b5ff4d035cfc7c"
}
]
},
@@ -58,14 +58,14 @@
"source": "integrity",
"locator": "bun.lock:packages[lodash@4.17.21]",
"value": "sha512-lodash-421",
"sha256": "b74a731eebc295f96d138d8f46d00893d3d352405ca422aa097c04ff5d5b40a6"
"sha256": "e83cd6aa810c1a8af47d6ae0eb621a8a5dc13b23ec08925ad9b5ff4d035cfc7c"
},
{
"kind": "metadata",
"source": "resolved",
"locator": "bun.lock:packages[lodash@4.17.21]",
"value": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"sha256": "b74a731eebc295f96d138d8f46d00893d3d352405ca422aa097c04ff5d5b40a6"
"sha256": "e83cd6aa810c1a8af47d6ae0eb621a8a5dc13b23ec08925ad9b5ff4d035cfc7c"
}
]
}

View File

@@ -22,14 +22,14 @@
"source": "integrity",
"locator": "bun.lock:packages[lodash@4.17.21]",
"value": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vz1kAmtILi\u002B8fm9nJMg7b0GN8sMEJz2mxG/S7mNxhWQ7\u002BD9bF8Q==",
"sha256": "ef266fe016f21c2b74d1c35bad087ffb5fc0913116a48e94037657201a33f812"
"sha256": "61ff5c565c08f6564bd16153c10feba4a171986510aaf40f84fe710eabd180c2"
},
{
"kind": "metadata",
"source": "resolved",
"locator": "bun.lock:packages[lodash@4.17.21]",
"value": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"sha256": "ef266fe016f21c2b74d1c35bad087ffb5fc0913116a48e94037657201a33f812"
"sha256": "61ff5c565c08f6564bd16153c10feba4a171986510aaf40f84fe710eabd180c2"
}
]
}

View File

@@ -20,14 +20,14 @@
"source": "integrity",
"locator": "bun.lock:packages[@babel/core@7.24.0]",
"value": "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR\u002BK9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw==",
"sha256": "ae452d62d7a3074cdbf5992884428a667d2b6176507524eb9b1e287049a1d6dd"
"sha256": "6ffde82e85e550d36bdb577210cd80c56cbd36c02dbfb4d8ec6ada27643bcd2d"
},
{
"kind": "metadata",
"source": "resolved",
"locator": "bun.lock:packages[@babel/core@7.24.0]",
"value": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz",
"sha256": "ae452d62d7a3074cdbf5992884428a667d2b6176507524eb9b1e287049a1d6dd"
"sha256": "6ffde82e85e550d36bdb577210cd80c56cbd36c02dbfb4d8ec6ada27643bcd2d"
}
]
},
@@ -52,14 +52,14 @@
"source": "integrity",
"locator": "bun.lock:packages[@types/node@20.11.0]",
"value": "sha512-o9bjXmDNcF7GbM4CNQpmi\u002BTutCgap/K3w1JyKgxXjVJa7b8XWCF/wPH2E/0Vz9e\u002BV1B3eXX0WCw\u002BINcAobvUag==",
"sha256": "ae452d62d7a3074cdbf5992884428a667d2b6176507524eb9b1e287049a1d6dd"
"sha256": "6ffde82e85e550d36bdb577210cd80c56cbd36c02dbfb4d8ec6ada27643bcd2d"
},
{
"kind": "metadata",
"source": "resolved",
"locator": "bun.lock:packages[@types/node@20.11.0]",
"value": "https://registry.npmjs.org/@types/node/-/node-20.11.0.tgz",
"sha256": "ae452d62d7a3074cdbf5992884428a667d2b6176507524eb9b1e287049a1d6dd"
"sha256": "6ffde82e85e550d36bdb577210cd80c56cbd36c02dbfb4d8ec6ada27643bcd2d"
}
]
}

View File

@@ -20,14 +20,14 @@
"source": "integrity",
"locator": "bun.lock:packages[lodash@4.17.21]",
"value": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vz1kAmtILi\u002B8fm9nJMg7b0GN8sMEJz2mxG/S7mNxhWQ7\u002BD9bF8Q==",
"sha256": "ef266fe016f21c2b74d1c35bad087ffb5fc0913116a48e94037657201a33f812"
"sha256": "61ff5c565c08f6564bd16153c10feba4a171986510aaf40f84fe710eabd180c2"
},
{
"kind": "metadata",
"source": "resolved",
"locator": "bun.lock:packages[lodash@4.17.21]",
"value": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"sha256": "ef266fe016f21c2b74d1c35bad087ffb5fc0913116a48e94037657201a33f812"
"sha256": "61ff5c565c08f6564bd16153c10feba4a171986510aaf40f84fe710eabd180c2"
}
]
}

View File

@@ -20,14 +20,14 @@
"source": "integrity",
"locator": "bun.lock:packages[safe-pkg@1.0.0]",
"value": "sha512-abc123",
"sha256": "608750aaec5150b6bb68702165a22d504bb6036fc5150d0b4b005727e21f4ade"
"sha256": "54dd0b2c2f30e59b29970d34350d083b295789e056e849361da5be932d1ef747"
},
{
"kind": "metadata",
"source": "resolved",
"locator": "bun.lock:packages[safe-pkg@1.0.0]",
"value": "https://registry.npmjs.org/safe-pkg/-/safe-pkg-1.0.0.tgz",
"sha256": "608750aaec5150b6bb68702165a22d504bb6036fc5150d0b4b005727e21f4ade"
"sha256": "54dd0b2c2f30e59b29970d34350d083b295789e056e849361da5be932d1ef747"
}
]
}

View File

@@ -20,14 +20,14 @@
"source": "integrity",
"locator": "bun.lock:packages[chalk@5.3.0]",
"value": "sha512-dLitG79d\u002BGV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos\u002Buw7WmWF4wUwBd9jxjocFC2w==",
"sha256": "3c0e7ee425c6a503bc114bb61316021a04115d148eb205ad996c0c320a33f4d1"
"sha256": "8706c5aecdc68ae4f06c6a2f1bfa9e431e473a961c2f32063911febaba0c65cc"
},
{
"kind": "metadata",
"source": "resolved",
"locator": "bun.lock:packages[chalk@5.3.0]",
"value": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz",
"sha256": "3c0e7ee425c6a503bc114bb61316021a04115d148eb205ad996c0c320a33f4d1"
"sha256": "8706c5aecdc68ae4f06c6a2f1bfa9e431e473a961c2f32063911febaba0c65cc"
}
]
}

View File

@@ -15,7 +15,7 @@
"kind": "file",
"source": "package.json",
"locator": "layers/layer1/app/package.json",
"sha256": "d846f429c41d17adeacfd418431ab4be4857b40a749eeea229d7be91644d6d5d"
"sha256": "23abb943f062b3ccdc18966eb36dfc48dd7ec4b5a6105851484fe2911946ecdd"
}
]
}

View File

@@ -94,7 +94,7 @@
"kind": "file",
"source": "package.json",
"locator": "packages/nested/tool/package.json",
"sha256": "9d7d0f85e36dbcd09eedf4d85a1a53a07f92bf768b1375f18a997ba0ee9295d9"
"sha256": "3011f57f07fab11b4ecb61788319bc9768d2577cafd9f53f37a7cac721fc77cf"
}
]
},

View File

@@ -220,35 +220,53 @@ public sealed class DotNetLanguageAnalyzerTests
{
var cancellationToken = TestContext.Current.CancellationToken;
var fixturePath = TestPaths.ResolveFixture("lang", "dotnet", "source-tree-only");
var tempRoot = TestPaths.CreateTemporaryDirectory();
var analyzers = new ILanguageAnalyzer[]
{
new DotNetLanguageAnalyzer()
};
var json = await LanguageAnalyzerTestHarness.RunToJsonAsync(
fixturePath,
analyzers,
cancellationToken);
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
Assert.True(root.ValueKind == JsonValueKind.Array, "Result root should be an array.");
Assert.Equal(2, root.GetArrayLength());
// Check that packages are declared-only
foreach (var component in root.EnumerateArray())
try
{
var metadata = component.GetProperty("metadata");
Assert.Equal("true", metadata.GetProperty("declaredOnly").GetString());
Assert.Equal("declared", metadata.GetProperty("provenance").GetString());
}
// Ensure this scenario is truly source-only even if fixture artifacts are present.
File.Copy(
Path.Combine(fixturePath, "Sample.App.csproj"),
Path.Combine(tempRoot, "Sample.App.csproj"),
overwrite: true);
File.Copy(
Path.Combine(fixturePath, "Directory.Packages.props"),
Path.Combine(tempRoot, "Directory.Packages.props"),
overwrite: true);
// Check specific packages
var newtonsoftJson = root.EnumerateArray()
.First(element => element.GetProperty("name").GetString() == "Newtonsoft.Json");
Assert.Equal("13.0.3", newtonsoftJson.GetProperty("version").GetString());
Assert.Equal("pkg:nuget/newtonsoft.json@13.0.3", newtonsoftJson.GetProperty("purl").GetString());
var json = await LanguageAnalyzerTestHarness.RunToJsonAsync(
tempRoot,
analyzers,
cancellationToken);
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
Assert.True(root.ValueKind == JsonValueKind.Array, "Result root should be an array.");
Assert.Equal(2, root.GetArrayLength());
// Check that packages are declared-only
foreach (var component in root.EnumerateArray())
{
var metadata = component.GetProperty("metadata");
Assert.Equal("true", metadata.GetProperty("declaredOnly").GetString());
Assert.Equal("declared", metadata.GetProperty("provenance").GetString());
}
// Check specific packages
var newtonsoftJson = root.EnumerateArray()
.First(element => element.GetProperty("name").GetString() == "Newtonsoft.Json");
Assert.Equal("13.0.3", newtonsoftJson.GetProperty("version").GetString());
Assert.Equal("pkg:nuget/newtonsoft.json@13.0.3", newtonsoftJson.GetProperty("purl").GetString());
}
finally
{
TestPaths.SafeDelete(tempRoot);
}
}
[Fact]

View File

@@ -4,7 +4,7 @@
Own test coverage for Cartographer service configuration and behavior.
## Responsibilities
- Maintain `StellaOps.Cartographer.Tests`.
- Maintain `StellaOps.Scanner.Cartographer.Tests`.
- Validate options defaults, validation, and integration wiring.
- Surface open work on `TASKS.md`; update statuses (TODO/DOING/DONE/BLOCKED/REVIEW).

View File

@@ -5,14 +5,14 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using Xunit;
namespace StellaOps.Cartographer.Tests;
namespace StellaOps.Scanner.Cartographer.Tests;
public class CartographerProgramTests
{
[Fact]
public async Task HealthEndpoints_ReturnOk()
{
using var factory = new WebApplicationFactory<StellaOps.Cartographer.CartographerEntryPoint>();
using var factory = new WebApplicationFactory<StellaOps.Scanner.Cartographer.CartographerEntryPoint>();
using var client = factory.CreateClient();
var cancellationToken = TestContext.Current.CancellationToken;
@@ -26,7 +26,7 @@ public class CartographerProgramTests
[Fact]
public void AuthorityOptions_InvalidIssuer_ThrowsOnStart()
{
using var factory = new WebApplicationFactory<StellaOps.Cartographer.CartographerEntryPoint>().WithWebHostBuilder(builder =>
using var factory = new WebApplicationFactory<StellaOps.Scanner.Cartographer.CartographerEntryPoint>().WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, config) =>
{

View File

@@ -1,8 +1,8 @@
using StellaOps.Auth.Abstractions;
using StellaOps.Cartographer.Options;
using StellaOps.Scanner.Cartographer.Options;
using Xunit;
namespace StellaOps.Cartographer.Tests.Options;
namespace StellaOps.Scanner.Cartographer.Tests.Options;
public class CartographerAuthorityOptionsConfiguratorTests
{

View File

@@ -7,7 +7,7 @@
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Cartographer/StellaOps.Cartographer.csproj" />
<ProjectReference Include="../../StellaOps.Scanner.Cartographer/StellaOps.Scanner.Cartographer.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -5,7 +5,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0135-M | DONE | Maintainability audit for StellaOps.Cartographer.Tests; revalidated 2026-01-06. |
| AUDIT-0135-T | DONE | Test coverage audit for StellaOps.Cartographer.Tests; revalidated 2026-01-06. |
| AUDIT-0135-M | DONE | Maintainability audit for StellaOps.Scanner.Cartographer.Tests (migrated from StellaOps.Cartographer.Tests); revalidated 2026-01-06. |
| AUDIT-0135-T | DONE | Test coverage audit for StellaOps.Scanner.Cartographer.Tests (migrated from StellaOps.Cartographer.Tests); revalidated 2026-01-06. |
| AUDIT-0135-A | DONE | Waived (test project; revalidated 2026-01-06). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -0,0 +1,16 @@
{
"spdxVersion": "SPDX-3.0.1",
"dataLicense": "CC0-1.0",
"name": "TestSbom",
"documentNamespace": "https://example.com/test",
"packages": [
{
"name": "Package1",
"version": "1.0.0"
},
{
"name": "Package2",
"version": "2.0.0"
}
]
}

View File

@@ -22,5 +22,6 @@
</ItemGroup>
<ItemGroup>
<None Update="Fixtures\*.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="Snapshots\*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -183,6 +183,7 @@ public sealed class CompositionRecipeServiceTests
JsonBytes = Array.Empty<byte>(),
JsonSha256 = "sha256:inventory123",
ContentHash = "sha256:inventory123",
CanonicalId = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
JsonMediaType = "application/vnd.cyclonedx+json",
ProtobufBytes = Array.Empty<byte>(),
ProtobufSha256 = "sha256:protobuf123",

View File

@@ -18,7 +18,7 @@
<ItemGroup>
<!-- Excititor: Trust vectors, claim scoring, calibration -->
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Core/StellaOps.Scanner.Core.csproj" />
<ProjectReference Include="../../../Excititor/__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
<ProjectReference Include="../../../Concelier/__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
<!-- Policy: Gates, merge, trust lattice engine -->
<ProjectReference Include="../../../Policy/__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />

View File

@@ -74,6 +74,8 @@ public sealed class ReachabilityResultFactoryTests
StackVerdict verdict,
bool l1Reachable = true,
ConfidenceLevel l1Confidence = ConfidenceLevel.High,
ImmutableArray<CallPath>? paths = null,
ImmutableArray<Entrypoint>? reachingEntrypoints = null,
bool l2Resolved = true,
ConfidenceLevel l2Confidence = ConfidenceLevel.High,
bool l3Gated = false,
@@ -89,6 +91,8 @@ public sealed class ReachabilityResultFactoryTests
StaticCallGraph = new ReachabilityLayer1
{
IsReachable = l1Reachable,
Paths = paths ?? [],
ReachingEntrypoints = reachingEntrypoints ?? [],
Confidence = l1Confidence,
AnalysisMethod = "static-dataflow"
},
@@ -382,23 +386,73 @@ public sealed class ReachabilityResultFactoryTests
}
[Fact]
public async Task CreateResultAsync_ExploitableVerdict_ReturnsUnknownAsPlaceholder()
public async Task CreateResultAsync_ExploitableVerdict_WithPathData_ReturnsAffected()
{
// Arrange - Exploitable verdict returns Unknown placeholder (caller should build PathWitness)
var stack = CreateStackWithVerdict(StackVerdict.Exploitable);
// Arrange
var entrypoint = new Entrypoint(
Name: "GET /orders",
Type: EntrypointType.HttpEndpoint,
Location: "OrdersController.cs",
Description: "Orders API");
var path = new CallPath
{
Entrypoint = entrypoint,
Sites =
[
new CallSite("OrdersController.Get", "OrdersController", "OrdersController.cs", 42, CallSiteType.Direct),
new CallSite("VulnParser.Parse", "VulnParser", "VulnParser.cs", 88, CallSiteType.Direct)
],
Confidence = 0.91
};
var stack = CreateStackWithVerdict(
StackVerdict.Exploitable,
paths: [path],
reachingEntrypoints: [entrypoint]);
// Act
var result = await _factory.CreateResultAsync(stack, DefaultContext);
// Assert - Returns Unknown as placeholder since PathWitness should be built separately
result.Verdict.Should().Be(WitnessVerdict.Unknown);
// Assert
result.Verdict.Should().Be(WitnessVerdict.Affected);
result.PathWitness.Should().NotBeNull();
result.PathWitness!.Entrypoint.Name.Should().Be(entrypoint.Name);
result.PathWitness.Path.Should().HaveCount(2);
result.PathWitness.Sink.Symbol.Should().Be(stack.Symbol.Name);
result.PathWitness.WitnessId.Should().StartWith("wit:sha256:");
result.PathWitness.ClaimId.Should().NotBeNullOrWhiteSpace();
result.PathWitness.PathHash.Should().NotBeNullOrWhiteSpace();
result.PathWitness.NodeHashes.Should().NotBeEmpty();
}
[Fact]
public async Task CreateResultAsync_LikelyExploitableVerdict_ReturnsUnknownAsPlaceholder()
public async Task CreateResultAsync_LikelyExploitable_WithEntrypointOnly_ReturnsAffected()
{
// Arrange
var stack = CreateStackWithVerdict(StackVerdict.LikelyExploitable);
var entrypoint = new Entrypoint(
Name: "message-handler",
Type: EntrypointType.MessageHandler,
Location: "consumer.cs",
Description: "Queue consumer");
var stack = CreateStackWithVerdict(
StackVerdict.LikelyExploitable,
reachingEntrypoints: [entrypoint]);
// Act
var result = await _factory.CreateResultAsync(stack, DefaultContext);
// Assert
result.Verdict.Should().Be(WitnessVerdict.Affected);
result.PathWitness.Should().NotBeNull();
result.PathWitness!.Entrypoint.Name.Should().Be(entrypoint.Name);
result.PathWitness.Path.Should().ContainSingle();
result.PathWitness.Path[0].Symbol.Should().Be(stack.Symbol.Name);
}
[Fact]
public async Task CreateResultAsync_ExploitableWithoutEntrypoint_ReturnsUnknownFallback()
{
// Arrange
var stack = CreateStackWithVerdict(StackVerdict.Exploitable);
// Act
var result = await _factory.CreateResultAsync(stack, DefaultContext);

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Runtime/StellaOps.Scanner.Runtime.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,197 @@
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.Runtime;
using StellaOps.Scanner.Runtime.Ebpf;
using StellaOps.Scanner.Runtime.Etw;
using StellaOps.TestKit;
using System.Text.Json;
using Xunit;
namespace StellaOps.Scanner.Runtime.Tests;
public sealed class TraceCollectorFixtureTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EbpfCollector_SealedMode_EmitsDeterministicFilteredEvents()
{
var resolver = new DeterministicSymbolResolver();
await using var collector = new EbpfTraceCollector(
NullLogger<EbpfTraceCollector>.Instance,
resolver,
TimeProvider.System);
var fixtureEvents = new[]
{
BuildEvent(timestamp: 25, pid: 200, tid: 1, caller: 0x2000, callee: 0x2001),
BuildEvent(timestamp: 10, pid: 100, tid: 2, caller: 0x1000, callee: 0x1001),
BuildEvent(timestamp: 20, pid: 100, tid: 3, caller: 0x1002, callee: 0x1003)
};
await collector.StartAsync(new TraceCollectorConfig
{
SealedMode = true,
ResolveSymbols = true,
TargetPid = 100,
MaxEventsPerSecond = int.MaxValue,
PreloadedEvents = fixtureEvents
});
var received = await ReadEventsAsync(collector.GetEventsAsync(), expectedCount: 2);
await collector.StopAsync();
Assert.Equal(2, received.Count);
Assert.Equal(10UL, received[0].Timestamp);
Assert.Equal(20UL, received[1].Timestamp);
Assert.All(received, evt => Assert.Equal((uint)100, evt.Pid));
Assert.All(received, evt => Assert.StartsWith("resolved_", evt.CallerSymbol, StringComparison.Ordinal));
var stats = collector.GetStatistics();
Assert.Equal(2, stats.EventsCollected);
Assert.False(stats.IsRunning);
Assert.Equal("sealed_replay", stats.Mode);
Assert.Contains(stats.Capability, new[] { "available", "sealed_fallback" });
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EtwCollector_SealedMode_LoadsFixtureFileInDeterministicOrder()
{
await using var collector = new EtwTraceCollector(
NullLogger<EtwTraceCollector>.Instance,
TimeProvider.System);
var fixturePath = Path.Combine(
Path.GetTempPath(),
$"stella-etw-fixture-{Guid.NewGuid():N}.json");
var fixtureEvents = new[]
{
BuildEvent(timestamp: 90, pid: 500, tid: 9, caller: 0x900, callee: 0x901),
BuildEvent(timestamp: 30, pid: 500, tid: 3, caller: 0x300, callee: 0x301)
};
await File.WriteAllBytesAsync(
fixturePath,
JsonSerializer.SerializeToUtf8Bytes(fixtureEvents));
try
{
await collector.StartAsync(new TraceCollectorConfig
{
SealedMode = true,
FixtureFilePath = fixturePath,
MaxEventsPerSecond = int.MaxValue
});
var received = await ReadEventsAsync(collector.GetEventsAsync(), expectedCount: 2);
await collector.StopAsync();
Assert.Equal(2, received.Count);
Assert.Equal(30UL, received[0].Timestamp);
Assert.Equal(90UL, received[1].Timestamp);
var stats = collector.GetStatistics();
Assert.Equal(2, stats.EventsCollected);
Assert.False(stats.IsRunning);
Assert.Equal("sealed_replay", stats.Mode);
Assert.Contains(stats.Capability, new[] { "available", "sealed_fallback" });
}
finally
{
if (File.Exists(fixturePath))
{
File.Delete(fixturePath);
}
}
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EbpfCollector_InvalidFixture_ReportsDeterministicHealthError()
{
var resolver = new DeterministicSymbolResolver();
await using var collector = new EbpfTraceCollector(
NullLogger<EbpfTraceCollector>.Instance,
resolver,
TimeProvider.System);
var fixturePath = Path.Combine(
Path.GetTempPath(),
$"stella-ebpf-invalid-{Guid.NewGuid():N}.json");
await File.WriteAllTextAsync(fixturePath, "{not-json");
try
{
await collector.StartAsync(new TraceCollectorConfig
{
SealedMode = true,
FixtureFilePath = fixturePath
});
var received = await ReadEventsAsync(collector.GetEventsAsync(), expectedCount: 1);
await collector.StopAsync();
Assert.Empty(received);
var stats = collector.GetStatistics();
Assert.Equal(0, stats.EventsCollected);
Assert.NotNull(stats.LastError);
Assert.StartsWith("fixture_load_failed:", stats.LastError!, StringComparison.Ordinal);
}
finally
{
if (File.Exists(fixturePath))
{
File.Delete(fixturePath);
}
}
}
private static async Task<List<RuntimeCallEvent>> ReadEventsAsync(
IAsyncEnumerable<RuntimeCallEvent> stream,
int expectedCount)
{
var output = new List<RuntimeCallEvent>(capacity: expectedCount);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
await foreach (var evt in stream.WithCancellation(cts.Token))
{
output.Add(evt);
if (output.Count >= expectedCount)
{
break;
}
}
return output;
}
private static RuntimeCallEvent BuildEvent(
ulong timestamp,
uint pid,
uint tid,
ulong caller,
ulong callee)
{
return new RuntimeCallEvent
{
Timestamp = timestamp,
Pid = pid,
Tid = tid,
CallerAddress = caller,
CalleeAddress = callee,
CallerSymbol = string.Empty,
CalleeSymbol = string.Empty,
BinaryPath = string.Empty
};
}
private sealed class DeterministicSymbolResolver : ISymbolResolver
{
public Task<string> ResolveSymbolAsync(
uint pid,
ulong address,
CancellationToken cancellationToken = default)
{
return Task.FromResult($"resolved_{pid:x}_{address:x}");
}
}
}

View File

@@ -0,0 +1,163 @@
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.Cache.Abstractions;
using StellaOps.Scanner.Runtime;
using StellaOps.Scanner.Runtime.Ingestion;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Scanner.Runtime.Tests;
public sealed class TraceIngestionServiceTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task IngestAsync_SameInputs_ProducesDeterministicTraceId()
{
var service = new TraceIngestionService(
new InMemoryFileCasStore(),
NullLogger<TraceIngestionService>.Instance,
TimeProvider.System);
var first = await service.IngestAsync(CreateEvents(), "scan-a", TestContext.Current.CancellationToken);
var second = await service.IngestAsync(CreateEvents(), "scan-a", TestContext.Current.CancellationToken);
Assert.Equal(first.TraceId, second.TraceId);
Assert.Equal(first.Edges.Count, second.Edges.Count);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task StoreAndGetTracesForScanAsync_IndexesAndReturnsSorted()
{
var service = new TraceIngestionService(
new InMemoryFileCasStore(),
NullLogger<TraceIngestionService>.Instance,
TimeProvider.System);
var traceA = await service.IngestAsync(CreateEvents(offset: 0), "scan-index", TestContext.Current.CancellationToken);
var traceB = await service.IngestAsync(CreateEvents(offset: 1_000_000), "scan-index", TestContext.Current.CancellationToken);
await service.StoreAsync(traceA, TestContext.Current.CancellationToken);
await service.StoreAsync(traceB, TestContext.Current.CancellationToken);
var traces = await service.GetTracesForScanAsync("scan-index", TestContext.Current.CancellationToken);
Assert.Equal(2, traces.Count);
Assert.True(string.CompareOrdinal(traces[0].TraceId, traces[1].TraceId) < 0);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task StoreAsync_IsIdempotentForScanIndex()
{
var service = new TraceIngestionService(
new InMemoryFileCasStore(),
NullLogger<TraceIngestionService>.Instance,
TimeProvider.System);
var trace = await service.IngestAsync(CreateEvents(), "scan-idempotent", TestContext.Current.CancellationToken);
await service.StoreAsync(trace, TestContext.Current.CancellationToken);
await service.StoreAsync(trace, TestContext.Current.CancellationToken);
var traces = await service.GetTracesForScanAsync("scan-idempotent", TestContext.Current.CancellationToken);
Assert.Single(traces);
Assert.Equal(trace.TraceId, traces[0].TraceId);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetTracesForScanAsync_UnknownScan_ReturnsEmpty()
{
var service = new TraceIngestionService(
new InMemoryFileCasStore(),
NullLogger<TraceIngestionService>.Instance,
TimeProvider.System);
var traces = await service.GetTracesForScanAsync("missing-scan", TestContext.Current.CancellationToken);
Assert.Empty(traces);
}
private static async IAsyncEnumerable<RuntimeCallEvent> CreateEvents(int offset = 0)
{
yield return new RuntimeCallEvent
{
Timestamp = (ulong)(1_000_000 + offset),
Pid = 101,
Tid = 1,
CallerAddress = 10,
CalleeAddress = 11,
CallerSymbol = "main",
CalleeSymbol = "handler",
BinaryPath = "/app/service"
};
yield return new RuntimeCallEvent
{
Timestamp = (ulong)(2_000_000 + offset),
Pid = 101,
Tid = 1,
CallerAddress = 11,
CalleeAddress = 12,
CallerSymbol = "handler",
CalleeSymbol = "sink",
BinaryPath = "/app/service"
};
await Task.CompletedTask;
}
private sealed class InMemoryFileCasStore : IFileContentAddressableStore
{
private readonly Dictionary<string, byte[]> _entries = new(StringComparer.Ordinal);
public ValueTask<FileCasEntry?> TryGetAsync(string sha256, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (!_entries.TryGetValue(sha256, out var payload))
{
return ValueTask.FromResult<FileCasEntry?>(null);
}
return ValueTask.FromResult<FileCasEntry?>(new FileCasEntry(
sha256,
payload.LongLength,
DateTimeOffset.UnixEpoch,
DateTimeOffset.UnixEpoch,
$"cas/{sha256}.bin"));
}
public async Task<FileCasEntry> PutAsync(FileCasPutRequest request, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
await using var copy = new MemoryStream();
await request.Content.CopyToAsync(copy, cancellationToken);
_entries[request.Sha256] = copy.ToArray();
if (!request.LeaveOpen)
{
request.Content.Dispose();
}
return new FileCasEntry(
request.Sha256,
_entries[request.Sha256].LongLength,
DateTimeOffset.UnixEpoch,
DateTimeOffset.UnixEpoch,
$"cas/{request.Sha256}.bin");
}
public Task<bool> RemoveAsync(string sha256, CancellationToken cancellationToken = default)
=> Task.FromResult(_entries.Remove(sha256));
public Task<int> EvictExpiredAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(0);
public Task<int> ExportAsync(string destinationDirectory, CancellationToken cancellationToken = default)
=> Task.FromResult(0);
public Task<int> ImportAsync(string sourceDirectory, CancellationToken cancellationToken = default)
=> Task.FromResult(0);
public Task<int> CompactAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(0);
}
}

View File

@@ -224,7 +224,7 @@ public sealed class DeltaVerdictSnapshotTests
var actualNorm = JsonSerializer.Serialize(
JsonSerializer.Deserialize<JsonElement>(actual), PrettyPrintOptions);
actualNorm.Should().Be(expectedNorm, "Delta verdict output should match snapshot");
Assert.Equal(expectedNorm, actualNorm);
}
}

View File

@@ -1,6 +1,7 @@
{
"predicateType": "delta-verdict.stella/v1",
"predicate": {
"unknownsBudget": null,
"beforeRevisionId": "rev-before-complex",
"afterRevisionId": "rev-after-complex",
"hasMaterialChange": true,
@@ -52,8 +53,7 @@
"afterProofSpine": null,
"beforeGraphRevisionId": null,
"afterGraphRevisionId": null,
"comparedAt": "2025-01-15T12:00:00\u002B00:00",
"unknownsBudget": null
"comparedAt": "2025-01-15T12:00:00\u002B00:00"
},
"_type": "https://in-toto.io/Statement/v1",
"subject": [

View File

@@ -1,6 +1,7 @@
{
"predicateType": "delta-verdict.stella/v1",
"predicate": {
"unknownsBudget": null,
"beforeRevisionId": "rev-before-001",
"afterRevisionId": "rev-after-001",
"hasMaterialChange": true,
@@ -26,8 +27,7 @@
"afterProofSpine": null,
"beforeGraphRevisionId": null,
"afterGraphRevisionId": null,
"comparedAt": "2025-01-15T12:00:00\u002B00:00",
"unknownsBudget": null
"comparedAt": "2025-01-15T12:00:00\u002B00:00"
},
"_type": "https://in-toto.io/Statement/v1",
"subject": [

View File

@@ -1,6 +1,7 @@
{
"predicateType": "delta-verdict.stella/v1",
"predicate": {
"unknownsBudget": null,
"beforeRevisionId": "rev-before-nochange",
"afterRevisionId": "rev-after-nochange",
"hasMaterialChange": false,
@@ -12,8 +13,7 @@
"afterProofSpine": null,
"beforeGraphRevisionId": null,
"afterGraphRevisionId": null,
"comparedAt": "2025-01-15T12:00:00\u002B00:00",
"unknownsBudget": null
"comparedAt": "2025-01-15T12:00:00\u002B00:00"
},
"_type": "https://in-toto.io/Statement/v1",
"subject": [

View File

@@ -1,6 +1,7 @@
{
"predicateType": "delta-verdict.stella/v1",
"predicate": {
"unknownsBudget": null,
"beforeRevisionId": "rev-spine-before",
"afterRevisionId": "rev-spine-after",
"hasMaterialChange": true,
@@ -32,8 +33,7 @@
},
"beforeGraphRevisionId": "graph-rev-before-001",
"afterGraphRevisionId": "graph-rev-after-001",
"comparedAt": "2025-01-15T12:00:00\u002B00:00",
"unknownsBudget": null
"comparedAt": "2025-01-15T12:00:00\u002B00:00"
},
"_type": "https://in-toto.io/Statement/v1",
"subject": [

View File

@@ -0,0 +1,302 @@
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Replay.Core;
using StellaOps.Scanner.ProofSpine;
using StellaOps.Scanner.Storage.Oci;
using StellaOps.TestKit;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Xunit;
namespace StellaOps.Scanner.Storage.Oci.Tests;
public sealed class SlicePullServiceTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ListReferrersWithCapabilityAsync_WhenReferrersSupported_ReturnsSupportedCapability()
{
var handler = new SlicePullHandler
{
ReferrersStatusCode = HttpStatusCode.OK,
ReferrersBody = """
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:abc",
"size": 128,
"artifactType": "application/vnd.dsse.envelope.v1+json"
}
]
}
"""
};
using var client = new HttpClient(handler);
var service = CreateService(client);
var reference = OciImageReference.Parse("registry.example/stellaops/demo:latest")!;
var result = await service.ListReferrersWithCapabilityAsync(reference, "sha256:subject");
Assert.Equal(OciReferrersCapability.Supported, result.Capability);
Assert.False(result.FallbackUsed);
Assert.Single(result.Referrers);
Assert.Equal("sha256:abc", result.Referrers[0].Digest);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ListReferrersWithCapabilityAsync_WhenUnsupported_UsesDeterministicFallbackTags()
{
var handler = new SlicePullHandler
{
ReferrersStatusCode = HttpStatusCode.NotFound,
TagsBody = """
{
"name": "stellaops/demo",
"tags": ["att-proof", "unrelated"]
}
""",
TaggedManifests =
{
["att-proof"] = new TaggedManifest(
"""
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"artifactType": "application/vnd.dsse.envelope.v1+json",
"subject": { "digest": "sha256:subject" },
"layers": []
}
""",
"sha256:attproof")
}
};
using var client = new HttpClient(handler);
var service = CreateService(client);
var reference = OciImageReference.Parse("registry.example/stellaops/demo:latest")!;
var result = await service.ListReferrersWithCapabilityAsync(reference, "sha256:subject");
Assert.Equal(OciReferrersCapability.Unsupported, result.Capability);
Assert.True(result.FallbackUsed);
Assert.Equal((int)HttpStatusCode.NotFound, result.StatusCode);
Assert.Single(result.Referrers);
Assert.Equal("sha256:attproof", result.Referrers[0].Digest);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task PullByDigestAsync_WhenDsseVerificationSucceeds_MarksSignatureVerified()
{
var sliceJson = """{"inputs":{"graphDigest":"sha256:g"},"query":{},"subgraph":{"nodes":[],"edges":[]},"verdict":{"status":"unknown","confidence":0.4},"manifest":{"scanId":"s","artifactDigest":"sha256:a","createdAtUtc":"2026-02-26T00:00:00Z","scannerVersion":"1","workerVersion":"1","concelierSnapshot":"","excititorSnapshot":"","latticePolicyHash":""}}""";
var sliceBytes = Encoding.UTF8.GetBytes(sliceJson);
var sliceDigest = Sha256Digest(sliceBytes);
var envelope = new DsseEnvelope(
PayloadType: "application/vnd.stellaops.slice+json",
Payload: Convert.ToBase64String(sliceBytes),
Signatures: [new DsseSignature("key-1", "sig-1")]);
var envelopeBytes = JsonSerializer.SerializeToUtf8Bytes(envelope);
var envelopeDigest = Sha256Digest(envelopeBytes);
var manifestBody = $$"""
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"layers": [
{ "mediaType": "{{OciMediaTypes.ReachabilitySlice}}", "digest": "{{sliceDigest}}", "size": {{sliceBytes.Length}} },
{ "mediaType": "{{OciMediaTypes.DsseEnvelope}}", "digest": "{{envelopeDigest}}", "size": {{envelopeBytes.Length}} }
]
}
""";
var handler = new SlicePullHandler
{
ManifestBody = manifestBody,
BlobBodies =
{
[sliceDigest] = sliceBytes,
[envelopeDigest] = envelopeBytes
}
};
var verifier = new StubDsseSigningService(new DsseVerificationOutcome(true, true, null));
using var client = new HttpClient(handler);
var service = CreateService(client, verifier);
var reference = OciImageReference.Parse("registry.example/stellaops/demo:latest")!;
var result = await service.PullByDigestAsync(reference, "sha256:subject");
Assert.True(result.Success);
Assert.True(result.SignatureVerified);
Assert.NotNull(result.DsseEnvelope);
Assert.Single(verifier.VerifiedEnvelopes);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task PullByDigestAsync_WhenDsseVerificationFails_ReturnsUnverifiedSlice()
{
var sliceJson = """{"inputs":{"graphDigest":"sha256:g"},"query":{},"subgraph":{"nodes":[],"edges":[]},"verdict":{"status":"unknown","confidence":0.4},"manifest":{"scanId":"s","artifactDigest":"sha256:a","createdAtUtc":"2026-02-26T00:00:00Z","scannerVersion":"1","workerVersion":"1","concelierSnapshot":"","excititorSnapshot":"","latticePolicyHash":""}}""";
var sliceBytes = Encoding.UTF8.GetBytes(sliceJson);
var sliceDigest = Sha256Digest(sliceBytes);
var envelope = new DsseEnvelope(
PayloadType: "application/vnd.stellaops.slice+json",
Payload: Convert.ToBase64String(sliceBytes),
Signatures: [new DsseSignature("key-1", "sig-1")]);
var envelopeBytes = JsonSerializer.SerializeToUtf8Bytes(envelope);
var envelopeDigest = Sha256Digest(envelopeBytes);
var manifestBody = $$"""
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"layers": [
{ "mediaType": "{{OciMediaTypes.ReachabilitySlice}}", "digest": "{{sliceDigest}}", "size": {{sliceBytes.Length}} },
{ "mediaType": "{{OciMediaTypes.DsseEnvelope}}", "digest": "{{envelopeDigest}}", "size": {{envelopeBytes.Length}} }
]
}
""";
var handler = new SlicePullHandler
{
ManifestBody = manifestBody,
BlobBodies =
{
[sliceDigest] = sliceBytes,
[envelopeDigest] = envelopeBytes
}
};
var verifier = new StubDsseSigningService(new DsseVerificationOutcome(false, false, "signature_invalid"));
using var client = new HttpClient(handler);
var service = CreateService(client, verifier);
var reference = OciImageReference.Parse("registry.example/stellaops/demo:latest")!;
var result = await service.PullByDigestAsync(reference, "sha256:subject");
Assert.True(result.Success);
Assert.False(result.SignatureVerified);
Assert.NotNull(result.DsseEnvelope);
}
private static SlicePullService CreateService(HttpClient client, IDsseSigningService? signingService = null)
{
return new SlicePullService(
client,
OciRegistryAuthorization.FromOptions("registry.example", new OciRegistryAuthOptions()),
options: new SlicePullOptions
{
VerifySignature = true,
EnableReferrersFallback = true
},
dsseSigningService: signingService,
logger: NullLogger<SlicePullService>.Instance,
timeProvider: TimeProvider.System);
}
private static string Sha256Digest(byte[] bytes)
=> $"sha256:{Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant()}";
private sealed class SlicePullHandler : HttpMessageHandler
{
public HttpStatusCode ReferrersStatusCode { get; init; } = HttpStatusCode.OK;
public string ReferrersBody { get; init; } = """{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[]}""";
public string TagsBody { get; init; } = """{"name":"stellaops/demo","tags":[]}""";
public string ManifestBody { get; init; } = """{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","layers":[]}""";
public Dictionary<string, byte[]> BlobBodies { get; } = new(StringComparer.Ordinal);
public Dictionary<string, TaggedManifest> TaggedManifests { get; } = new(StringComparer.Ordinal);
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
if (path.Contains("/referrers/", StringComparison.Ordinal))
{
return Task.FromResult(new HttpResponseMessage(ReferrersStatusCode)
{
Content = new StringContent(ReferrersBody, Encoding.UTF8, "application/json")
});
}
if (path.EndsWith("/tags/list", StringComparison.Ordinal))
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(TagsBody, Encoding.UTF8, "application/json")
});
}
if (path.Contains("/manifests/", StringComparison.Ordinal))
{
var tagOrDigest = Uri.UnescapeDataString(path[(path.LastIndexOf('/') + 1)..]);
if (TaggedManifests.TryGetValue(tagOrDigest, out var tagged))
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(tagged.Body, Encoding.UTF8, "application/json")
};
response.Headers.TryAddWithoutValidation("Docker-Content-Digest", tagged.Digest);
return Task.FromResult(response);
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(ManifestBody, Encoding.UTF8, "application/json")
});
}
if (path.Contains("/blobs/", StringComparison.Ordinal))
{
var digest = Uri.UnescapeDataString(path[(path.LastIndexOf('/') + 1)..]);
if (BlobBodies.TryGetValue(digest, out var content))
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(content)
});
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
}
}
private sealed record TaggedManifest(string Body, string Digest);
private sealed class StubDsseSigningService : IDsseSigningService
{
private readonly DsseVerificationOutcome _verificationOutcome;
public StubDsseSigningService(DsseVerificationOutcome verificationOutcome)
{
_verificationOutcome = verificationOutcome;
}
public List<DsseEnvelope> VerifiedEnvelopes { get; } = new();
public Task<DsseEnvelope> SignAsync(object payload, string payloadType, ICryptoProfile cryptoProfile, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<DsseVerificationOutcome> VerifyAsync(DsseEnvelope envelope, CancellationToken cancellationToken = default)
{
VerifiedEnvelopes.Add(envelope);
return Task.FromResult(_verificationOutcome);
}
}
}

View File

@@ -0,0 +1,183 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.Scanner.Contracts;
using StellaOps.Scanner.ReachabilityDrift;
using StellaOps.Scanner.Storage.Postgres;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Scanner.Storage.Tests;
[Collection("scanner-postgres")]
public sealed class ReachabilityDriftRepositorySchemaFallbackTests : IAsyncLifetime
{
private const string MissingSchemaName = "drift_missing";
private const string DefaultSchemaName = ScannerStorageDefaults.DefaultSchemaName;
private readonly ScannerPostgresFixture _fixture;
private ScannerDataSource _dataSource = null!;
private PostgresReachabilityDriftResultRepository _repository = null!;
public ReachabilityDriftRepositorySchemaFallbackTests(ScannerPostgresFixture fixture)
{
_fixture = fixture;
}
public async ValueTask InitializeAsync()
{
await _fixture.TruncateAllTablesAsync();
await PrepareDefaultSchemaDriftTablesAsync();
await _fixture.ExecuteSqlAsync($"CREATE SCHEMA IF NOT EXISTS {MissingSchemaName};");
var options = new ScannerStorageOptions
{
Postgres = new PostgresOptions
{
ConnectionString = _fixture.ConnectionString,
SchemaName = MissingSchemaName
}
};
_dataSource = new ScannerDataSource(
Options.Create(options),
NullLogger<ScannerDataSource>.Instance);
_repository = new PostgresReachabilityDriftResultRepository(
_dataSource,
NullLogger<PostgresReachabilityDriftResultRepository>.Instance);
}
public async ValueTask DisposeAsync()
{
if (_dataSource is not null)
{
await _dataSource.DisposeAsync();
}
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Repository_FallsBackToDefaultSchema_WhenConfiguredSchemaLacksDriftTables()
{
// Arrange
const string tenantId = "tenant-fallback";
var driftId = Guid.NewGuid();
var result = new ReachabilityDriftResult
{
Id = driftId,
BaseScanId = "scan-base-fallback",
HeadScanId = "scan-head-fallback",
Language = "dotnet",
DetectedAt = DateTimeOffset.UtcNow,
ResultDigest = "sha256:drift-fallback",
NewlyReachable =
[
CreateSink(
"sink-reachable",
DriftDirection.BecameReachable,
"Namespace.Service.Entry")
],
NewlyUnreachable =
[
CreateSink(
"sink-unreachable",
DriftDirection.BecameUnreachable,
"Namespace.Service.Legacy")
]
};
// Act
await _repository.StoreAsync(result, tenantId: tenantId);
var latest = await _repository.TryGetLatestForHeadAsync(
result.HeadScanId,
result.Language,
tenantId: tenantId);
var byId = await _repository.TryGetByIdAsync(result.Id, tenantId: tenantId);
var exists = await _repository.ExistsAsync(result.Id, tenantId: tenantId);
var reachableSinks = await _repository.ListSinksAsync(
result.Id,
DriftDirection.BecameReachable,
offset: 0,
limit: 10,
tenantId: tenantId);
// Assert
Assert.NotNull(latest);
Assert.NotNull(byId);
Assert.True(exists);
Assert.Equal(result.ResultDigest, latest!.ResultDigest);
Assert.Equal(result.ResultDigest, byId!.ResultDigest);
Assert.Single(reachableSinks);
Assert.Equal("sink-reachable", reachableSinks[0].SinkNodeId);
var otherTenant = await _repository.TryGetLatestForHeadAsync(
result.HeadScanId,
result.Language,
tenantId: "tenant-other");
Assert.Null(otherTenant);
}
private static DriftedSink CreateSink(string sinkNodeId, DriftDirection direction, string symbol)
{
return new DriftedSink
{
Id = Guid.NewGuid(),
SinkNodeId = sinkNodeId,
Symbol = symbol,
SinkCategory = SinkCategory.SqlInjection,
Direction = direction,
Cause = DriftCause.GuardRemoved(symbol),
Path = new CompressedPath
{
Entrypoint = new PathNode
{
NodeId = "entry-1",
Symbol = "Program.Main"
},
Sink = new PathNode
{
NodeId = sinkNodeId,
Symbol = symbol
},
IntermediateCount = 0,
KeyNodes = ImmutableArray<PathNode>.Empty,
FullPath = ["entry-1", sinkNodeId]
}
};
}
private async Task PrepareDefaultSchemaDriftTablesAsync()
{
var sourceSchema = QuoteIdentifier(_fixture.SchemaName);
var defaultSchema = QuoteIdentifier(DefaultSchemaName);
var sql = $"""
CREATE SCHEMA IF NOT EXISTS {defaultSchema};
DROP TABLE IF EXISTS {defaultSchema}.drifted_sinks CASCADE;
DROP TABLE IF EXISTS {defaultSchema}.reachability_drift_results CASCADE;
CREATE TABLE {defaultSchema}.reachability_drift_results
(LIKE {sourceSchema}.reachability_drift_results INCLUDING ALL);
CREATE TABLE {defaultSchema}.drifted_sinks
(LIKE {sourceSchema}.drifted_sinks INCLUDING ALL);
""";
await _fixture.ExecuteSqlAsync(sql);
}
private static string QuoteIdentifier(string identifier)
{
var escaped = identifier.Replace("\"", "\"\"", StringComparison.Ordinal);
return $"\"{escaped}\"";
}
}

View File

@@ -52,6 +52,7 @@ public sealed class SbomValidationPipelineTests
JsonBytes = Encoding.UTF8.GetBytes("{}"),
JsonSha256 = "abc123",
ContentHash = "abc123",
CanonicalId = "abc123",
JsonMediaType = "application/vnd.cyclonedx+json",
ProtobufBytes = Array.Empty<byte>(),
ProtobufSha256 = "def456",
@@ -67,6 +68,7 @@ public sealed class SbomValidationPipelineTests
JsonBytes = Encoding.UTF8.GetBytes("{}"),
JsonSha256 = "xyz789",
ContentHash = "xyz789",
CanonicalId = "xyz789",
JsonMediaType = "application/vnd.cyclonedx+json",
ProtobufBytes = Array.Empty<byte>(),
ProtobufSha256 = "uvw012",

View File

@@ -1,4 +1,4 @@
// -----------------------------------------------------------------------------
// -----------------------------------------------------------------------------
// ActionablesEndpointsTests.cs
// Sprint: SPRINT_4200_0002_0006_delta_compare_api
// Description: Integration tests for actionables engine endpoints.
@@ -8,9 +8,9 @@ using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Scanner.WebService.Tests;
/// <summary>
@@ -21,135 +21,156 @@ public sealed class ActionablesEndpointsTests
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetDeltaActionables_ValidDeltaId_ReturnsActionables()
[Fact]
public async Task GetDeltaActionables_UnknownDelta_ReturnsNotFound()
{
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<ActionablesResponseDto>(SerializerOptions);
Assert.NotNull(result);
Assert.Equal("delta-12345678", result!.DeltaId);
Assert.NotNull(result.Actionables);
var response = await client.GetAsync("/api/v1/actionables/delta/cmp-missing", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetDeltaActionables_SortedByPriority()
[Fact]
public async Task GetDeltaActionables_ValidDelta_ReturnsDerivedDeterministicActionables()
{
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678");
var result = await response.Content.ReadFromJsonAsync<ActionablesResponseDto>(SerializerOptions);
var delta = await CreateDeltaAsync(client);
Assert.NotNull(result);
if (result!.Actionables.Count > 1)
var first = await GetActionablesAsync(client, delta.ComparisonId);
var second = await GetActionablesAsync(client, delta.ComparisonId);
Assert.Equal(delta.ComparisonId, first.DeltaId);
Assert.NotNull(first.Actionables);
Assert.Equal(first.Actionables.Select(a => a.Id), second.Actionables.Select(a => a.Id));
Assert.All(first.Actionables, actionable => Assert.StartsWith("act-", actionable.Id, StringComparison.Ordinal));
var changedVulnIds = delta.Vulnerabilities!
.Where(v => v.ChangeType.Equals("Added", StringComparison.OrdinalIgnoreCase)
|| v.ChangeType.Equals("Modified", StringComparison.OrdinalIgnoreCase))
.Select(v => v.VulnId)
.ToHashSet(StringComparer.Ordinal);
foreach (var actionable in first.Actionables.Where(a => a.Type is "upgrade" or "investigate"))
{
var priorities = result.Actionables.Select(GetPriorityOrder).ToList();
Assert.True(priorities.SequenceEqual(priorities.Order()));
Assert.NotNull(actionable.CveIds);
Assert.NotEmpty(actionable.CveIds!);
Assert.Contains(actionable.CveIds!, id => changedVulnIds.Contains(id));
}
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task GetDeltaActionables_SortedByPriorityThenId()
{
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var delta = await CreateDeltaAsync(client);
var result = await GetActionablesAsync(client, delta.ComparisonId);
var actual = result.Actionables.Select(a => (Priority: GetPriorityOrder(a.Priority), a.Id)).ToList();
var expected = actual.OrderBy(x => x.Priority).ThenBy(x => x.Id, StringComparer.Ordinal).ToList();
Assert.Equal(expected, actual);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetActionablesByPriority_Critical_FiltersCorrectly()
{
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678/by-priority/critical");
var delta = await CreateDeltaAsync(client);
var response = await client.GetAsync(
$"/api/v1/actionables/delta/{delta.ComparisonId}/by-priority/critical",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<ActionablesResponseDto>(SerializerOptions);
var result = await response.Content.ReadFromJsonAsync<ActionablesResponseDto>(SerializerOptions, TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.All(result!.Actionables, a => Assert.Equal("critical", a.Priority, StringComparer.OrdinalIgnoreCase));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task GetActionablesByPriority_InvalidPriority_ReturnsBadRequest()
{
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678/by-priority/invalid");
var delta = await CreateDeltaAsync(client);
var response = await client.GetAsync(
$"/api/v1/actionables/delta/{delta.ComparisonId}/by-priority/invalid",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task GetActionablesByType_Upgrade_FiltersCorrectly()
{
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678/by-type/upgrade");
var delta = await CreateDeltaAsync(client);
var response = await client.GetAsync(
$"/api/v1/actionables/delta/{delta.ComparisonId}/by-type/upgrade",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<ActionablesResponseDto>(SerializerOptions);
var result = await response.Content.ReadFromJsonAsync<ActionablesResponseDto>(SerializerOptions, TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.All(result!.Actionables, a => Assert.Equal("upgrade", a.Type, StringComparer.OrdinalIgnoreCase));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetActionablesByType_Vex_FiltersCorrectly()
{
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678/by-type/vex");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<ActionablesResponseDto>(SerializerOptions);
Assert.NotNull(result);
Assert.All(result!.Actionables, a => Assert.Equal("vex", a.Type, StringComparer.OrdinalIgnoreCase));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task GetActionablesByType_InvalidType_ReturnsBadRequest()
{
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678/by-type/invalid");
var delta = await CreateDeltaAsync(client);
var response = await client.GetAsync(
$"/api/v1/actionables/delta/{delta.ComparisonId}/by-type/invalid",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetDeltaActionables_IncludesEstimatedEffort()
[Fact]
public async Task GetDeltaActionables_IncludesValidEstimatedEffortValues()
{
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/actionables/delta/delta-12345678");
var result = await response.Content.ReadFromJsonAsync<ActionablesResponseDto>(SerializerOptions);
var delta = await CreateDeltaAsync(client);
var result = await GetActionablesAsync(client, delta.ComparisonId);
Assert.NotNull(result);
foreach (var actionable in result!.Actionables)
foreach (var actionable in result.Actionables)
{
Assert.NotNull(actionable.EstimatedEffort);
Assert.Contains(actionable.EstimatedEffort, new[] { "trivial", "low", "medium", "high" });
}
}
private static int GetPriorityOrder(ActionableDto actionable)
private static int GetPriorityOrder(string priority)
{
return actionable.Priority.ToLowerInvariant() switch
return priority.ToLowerInvariant() switch
{
"critical" => 0,
"high" => 1,
@@ -158,4 +179,33 @@ public sealed class ActionablesEndpointsTests
_ => 4
};
}
private static async Task<DeltaCompareResponseDto> CreateDeltaAsync(HttpClient client)
{
var request = new DeltaCompareRequestDto
{
BaseDigest = "sha256:base-actionables",
TargetDigest = "sha256:target-actionables",
IncludeVulnerabilities = true,
IncludeComponents = true,
IncludePolicyDiff = true
};
var response = await client.PostAsJsonAsync("/api/v1/delta/compare", request, TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var delta = await response.Content.ReadFromJsonAsync<DeltaCompareResponseDto>(SerializerOptions, TestContext.Current.CancellationToken);
Assert.NotNull(delta);
return delta!;
}
private static async Task<ActionablesResponseDto> GetActionablesAsync(HttpClient client, string deltaId)
{
var response = await client.GetAsync($"/api/v1/actionables/delta/{deltaId}", TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var actionables = await response.Content.ReadFromJsonAsync<ActionablesResponseDto>(SerializerOptions, TestContext.Current.CancellationToken);
Assert.NotNull(actionables);
return actionables!;
}
}

View File

@@ -1,4 +1,4 @@
// -----------------------------------------------------------------------------
// -----------------------------------------------------------------------------
// DeltaCompareEndpointsTests.cs
// Sprint: SPRINT_4200_0002_0006_delta_compare_api
// Description: Integration tests for delta compare endpoints.
@@ -8,10 +8,9 @@ using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.TestKit;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Scanner.WebService.Tests;
/// <summary>
@@ -22,37 +21,59 @@ public sealed class DeltaCompareEndpointsTests
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task PostCompare_ValidRequest_ReturnsComparisonResult()
[Fact]
public async Task PostCompare_ValidRequest_ComputesDerivedSummaryAndPersistsById()
{
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var request = new DeltaCompareRequestDto
{
BaseDigest = "sha256:base123",
TargetDigest = "sha256:target456",
IncludeVulnerabilities = true,
IncludeComponents = true,
IncludePolicyDiff = true
};
var result = await PostCompareAsync(
client,
new DeltaCompareRequestDto
{
BaseDigest = "sha256:base123",
TargetDigest = "sha256:target456",
IncludeVulnerabilities = true,
IncludeComponents = true,
IncludePolicyDiff = true
});
var response = await client.PostAsJsonAsync("/api/v1/delta/compare", request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<DeltaCompareResponseDto>(SerializerOptions, TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.NotNull(result!.Base);
Assert.NotNull(result.Target);
Assert.NotNull(result.Summary);
Assert.NotNull(result.Vulnerabilities);
Assert.NotNull(result.Components);
Assert.NotNull(result.PolicyDiff);
Assert.NotEmpty(result.Vulnerabilities);
Assert.NotEmpty(result.ComparisonId);
Assert.StartsWith("cmp-", result.ComparisonId, StringComparison.Ordinal);
Assert.Equal("sha256:base123", result.Base.Digest);
Assert.Equal("sha256:target456", result.Target.Digest);
Assert.Equal(
result.Vulnerabilities.Count(v => v.ChangeType.Equals("Added", StringComparison.OrdinalIgnoreCase)),
result.Summary.Added);
Assert.Equal(
result.Vulnerabilities.Count(v => v.ChangeType.Equals("Removed", StringComparison.OrdinalIgnoreCase)),
result.Summary.Removed);
Assert.Equal(
result.Vulnerabilities.Count(v => v.ChangeType.Equals("Modified", StringComparison.OrdinalIgnoreCase)),
result.Summary.Modified);
Assert.Equal(
result.Vulnerabilities.Count(v => v.ChangeType.Equals("Unchanged", StringComparison.OrdinalIgnoreCase)),
result.Summary.Unchanged);
var persisted = await client.GetFromJsonAsync<DeltaCompareResponseDto>(
$"/api/v1/delta/{result.ComparisonId}",
TestContext.Current.CancellationToken);
Assert.NotNull(persisted);
Assert.Equal(result.ComparisonId, persisted!.ComparisonId);
Assert.Equal(result.Summary.RiskDirection, persisted.Summary.RiskDirection);
Assert.Equal(result.Summary.Added, persisted.Summary.Added);
Assert.Equal(result.Summary.Removed, persisted.Summary.Removed);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task PostCompare_MissingBaseDigest_ReturnsBadRequest()
{
await using var factory = new ScannerApplicationFactory();
@@ -61,7 +82,7 @@ public sealed class DeltaCompareEndpointsTests
var request = new DeltaCompareRequestDto
{
BaseDigest = "",
BaseDigest = string.Empty,
TargetDigest = "sha256:target456"
};
@@ -70,7 +91,7 @@ public sealed class DeltaCompareEndpointsTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task PostCompare_MissingTargetDigest_ReturnsBadRequest()
{
await using var factory = new ScannerApplicationFactory();
@@ -80,7 +101,7 @@ public sealed class DeltaCompareEndpointsTests
var request = new DeltaCompareRequestDto
{
BaseDigest = "sha256:base123",
TargetDigest = ""
TargetDigest = string.Empty
};
var response = await client.PostAsJsonAsync("/api/v1/delta/compare", request, TestContext.Current.CancellationToken);
@@ -88,50 +109,69 @@ public sealed class DeltaCompareEndpointsTests
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetQuickDiff_ValidDigests_ReturnsQuickSummary()
[Fact]
public async Task GetQuickDiff_UsesComparisonDerivedValues()
{
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/delta/quick?baseDigest=sha256:base123&targetDigest=sha256:target456");
var compare = await PostCompareAsync(
client,
new DeltaCompareRequestDto
{
BaseDigest = "sha256:base123",
TargetDigest = "sha256:target456",
IncludeVulnerabilities = true,
IncludeComponents = true,
IncludePolicyDiff = true
});
var response = await client.GetAsync(
"/api/v1/delta/quick?baseDigest=sha256:base123&targetDigest=sha256:target456",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<QuickDiffSummaryDto>(SerializerOptions);
var result = await response.Content.ReadFromJsonAsync<QuickDiffSummaryDto>(
SerializerOptions,
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Equal("sha256:base123", result!.BaseDigest);
Assert.Equal("sha256:target456", result.TargetDigest);
Assert.NotEmpty(result.RiskDirection);
Assert.Equal(compare.Summary.RiskDirection, result.RiskDirection);
Assert.Equal(compare.Summary.SeverityChanges.CriticalAdded, result.CriticalAdded);
Assert.Equal(compare.Summary.SeverityChanges.CriticalRemoved, result.CriticalRemoved);
Assert.Equal(compare.Summary.SeverityChanges.HighAdded, result.HighAdded);
Assert.Equal(compare.Summary.SeverityChanges.HighRemoved, result.HighRemoved);
Assert.NotEmpty(result.Summary);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task GetQuickDiff_MissingDigest_ReturnsBadRequest()
{
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/delta/quick?baseDigest=sha256:base123");
var response = await client.GetAsync("/api/v1/delta/quick?baseDigest=sha256:base123", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task GetComparison_NotFound_ReturnsNotFound()
{
await using var factory = new ScannerApplicationFactory();
await factory.InitializeAsync();
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/v1/delta/nonexistent-id");
var response = await client.GetAsync("/api/v1/delta/nonexistent-id", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task PostCompare_DeterministicComparisonId_SameInputsSameId()
{
await using var factory = new ScannerApplicationFactory();
@@ -144,14 +184,27 @@ public sealed class DeltaCompareEndpointsTests
TargetDigest = "sha256:target456"
};
var response1 = await client.PostAsJsonAsync("/api/v1/delta/compare", request);
var result1 = await response1.Content.ReadFromJsonAsync<DeltaCompareResponseDto>(SerializerOptions);
var result1 = await PostCompareAsync(client, request);
var result2 = await PostCompareAsync(client, request);
var response2 = await client.PostAsJsonAsync("/api/v1/delta/compare", request);
var result2 = await response2.Content.ReadFromJsonAsync<DeltaCompareResponseDto>(SerializerOptions);
Assert.Equal(result1.ComparisonId, result2.ComparisonId);
}
Assert.NotNull(result1);
Assert.NotNull(result2);
Assert.Equal(result1!.ComparisonId, result2!.ComparisonId);
private static async Task<DeltaCompareResponseDto> PostCompareAsync(
HttpClient client,
DeltaCompareRequestDto request)
{
var response = await client.PostAsJsonAsync(
"/api/v1/delta/compare",
request,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<DeltaCompareResponseDto>(
SerializerOptions,
TestContext.Current.CancellationToken);
Assert.NotNull(result);
return result!;
}
}

View File

@@ -0,0 +1,110 @@
using StellaOps.Policy.Scoring;
using StellaOps.Scanner.WebService.Services;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Scanner.WebService.Tests;
public sealed class DeterministicScoringServiceTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ReplayScoreAsync_SameInputs_ReturnsStableOutputs()
{
var service = new DeterministicScoringService();
var seed = Convert.FromHexString("00112233445566778899AABBCCDDEEFF");
var freeze = new DateTimeOffset(2026, 03, 04, 12, 00, 00, TimeSpan.Zero);
var ledgerA = new ProofLedger();
var ledgerB = new ProofLedger();
var first = await service.ReplayScoreAsync(
scanId: "scan-a",
concelierSnapshotHash: "sha256:concelier-a",
excititorSnapshotHash: "sha256:excititor-a",
latticePolicyHash: "sha256:policy-a",
seed: seed,
freezeTimestamp: freeze,
ledger: ledgerA,
cancellationToken: TestContext.Current.CancellationToken);
var second = await service.ReplayScoreAsync(
scanId: "scan-a",
concelierSnapshotHash: "sha256:concelier-a",
excititorSnapshotHash: "sha256:excititor-a",
latticePolicyHash: "sha256:policy-a",
seed: seed,
freezeTimestamp: freeze,
ledger: ledgerB,
cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(first.Score, second.Score);
Assert.Equal(first.CanonicalInputHash, second.CanonicalInputHash);
Assert.Equal(first.CanonicalInputPayload, second.CanonicalInputPayload);
Assert.Equal(first.SeedHex, second.SeedHex);
Assert.Equal(first.FormulaVersion, second.FormulaVersion);
Assert.Equal(
first.Factors.Select(f => (f.Name, f.Weight, f.Raw, f.Weighted, f.Source)),
second.Factors.Select(f => (f.Name, f.Weight, f.Raw, f.Weighted, f.Source)));
Assert.Equal(2, ledgerA.Nodes.Count);
Assert.Equal(2, ledgerB.Nodes.Count);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ReplayScoreAsync_DifferentFreezeTimestamp_ChangesCanonicalInputHash()
{
var service = new DeterministicScoringService();
var seed = Convert.FromHexString("00112233445566778899AABBCCDDEEFF");
var first = await service.ReplayScoreAsync(
scanId: "scan-a",
concelierSnapshotHash: "sha256:concelier-a",
excititorSnapshotHash: "sha256:excititor-a",
latticePolicyHash: "sha256:policy-a",
seed: seed,
freezeTimestamp: new DateTimeOffset(2026, 03, 04, 12, 00, 00, TimeSpan.Zero),
ledger: new ProofLedger(),
cancellationToken: TestContext.Current.CancellationToken);
var second = await service.ReplayScoreAsync(
scanId: "scan-a",
concelierSnapshotHash: "sha256:concelier-a",
excititorSnapshotHash: "sha256:excititor-a",
latticePolicyHash: "sha256:policy-a",
seed: seed,
freezeTimestamp: new DateTimeOffset(2026, 03, 04, 12, 01, 00, TimeSpan.Zero),
ledger: new ProofLedger(),
cancellationToken: TestContext.Current.CancellationToken);
Assert.NotEqual(first.CanonicalInputHash, second.CanonicalInputHash);
Assert.NotEqual(first.CanonicalInputPayload, second.CanonicalInputPayload);
Assert.Equal(first.Score, second.Score);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ReplayScoreAsync_UsesFactorizedRoundedComposition()
{
var service = new DeterministicScoringService();
var seed = Convert.FromHexString("00112233445566778899AABBCCDDEEFF");
var result = await service.ReplayScoreAsync(
scanId: "scan-a",
concelierSnapshotHash: "sha256:concelier-a",
excititorSnapshotHash: "sha256:excititor-a",
latticePolicyHash: "sha256:policy-a",
seed: seed,
freezeTimestamp: new DateTimeOffset(2026, 03, 04, 12, 00, 00, TimeSpan.Zero),
ledger: new ProofLedger(),
cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal("v2.factorized", result.FormulaVersion);
Assert.Equal(["cvss", "epss", "reachability", "provenance"], result.Factors.Select(f => f.Name));
var expectedScore = Math.Round(result.Factors.Sum(f => f.Weighted), 6, MidpointRounding.ToEven);
expectedScore = Math.Clamp(expectedScore, 0.0, 1.0);
Assert.Equal(expectedScore, result.Score);
}
}

View File

@@ -35,8 +35,8 @@ public sealed class NotifierIngestionTests
Priority = "critical"
};
var orchestratorEvent = CreateTestEvent(metadata);
var json = OrchestratorEventSerializer.Serialize(orchestratorEvent);
var jobEngineEvent = CreateTestEvent(metadata);
var json = JobEngineEventSerializer.Serialize(jobEngineEvent);
var node = JsonNode.Parse(json)?.AsObject();
Assert.NotNull(node);
@@ -59,10 +59,10 @@ public sealed class NotifierIngestionTests
[Fact]
public void NotifierMetadata_OmittedWhenNull()
{
var orchestratorEvent = new OrchestratorEvent
var jobEngineEvent = new JobEngineEvent
{
EventId = Guid.NewGuid(),
Kind = OrchestratorEventKinds.ScannerReportReady,
Kind = JobEngineEventKinds.ScannerReportReady,
Version = 1,
Tenant = "test-tenant",
OccurredAt = DateTimeOffset.UtcNow,
@@ -82,7 +82,7 @@ public sealed class NotifierIngestionTests
Notifier = null // Explicitly null
};
var json = OrchestratorEventSerializer.Serialize(orchestratorEvent);
var json = JobEngineEventSerializer.Serialize(jobEngineEvent);
var node = JsonNode.Parse(json)?.AsObject();
Assert.NotNull(node);
@@ -107,10 +107,10 @@ public sealed class NotifierIngestionTests
[Fact]
public void ScanStartedEvent_SerializesForNotifier()
{
var orchestratorEvent = new OrchestratorEvent
var jobEngineEvent = new JobEngineEvent
{
EventId = Guid.NewGuid(),
Kind = OrchestratorEventKinds.ScannerScanStarted,
Kind = JobEngineEventKinds.ScannerScanStarted,
Version = 1,
Tenant = "test-tenant",
OccurredAt = DateTimeOffset.Parse("2025-12-07T10:00:00Z"),
@@ -137,11 +137,11 @@ public sealed class NotifierIngestionTests
}
};
var json = OrchestratorEventSerializer.Serialize(orchestratorEvent);
var json = JobEngineEventSerializer.Serialize(jobEngineEvent);
var node = JsonNode.Parse(json)?.AsObject();
Assert.NotNull(node);
Assert.Equal(OrchestratorEventKinds.ScannerScanStarted, node["kind"]?.GetValue<string>());
Assert.Equal(JobEngineEventKinds.ScannerScanStarted, node["kind"]?.GetValue<string>());
var payload = node["payload"]?.AsObject();
Assert.NotNull(payload);
@@ -157,10 +157,10 @@ public sealed class NotifierIngestionTests
[Fact]
public void ScanFailedEvent_SerializesWithErrorDetails()
{
var orchestratorEvent = new OrchestratorEvent
var jobEngineEvent = new JobEngineEvent
{
EventId = Guid.NewGuid(),
Kind = OrchestratorEventKinds.ScannerScanFailed,
Kind = JobEngineEventKinds.ScannerScanFailed,
Version = 1,
Tenant = "test-tenant",
OccurredAt = DateTimeOffset.Parse("2025-12-07T10:05:00Z"),
@@ -200,11 +200,11 @@ public sealed class NotifierIngestionTests
}
};
var json = OrchestratorEventSerializer.Serialize(orchestratorEvent);
var json = JobEngineEventSerializer.Serialize(jobEngineEvent);
var node = JsonNode.Parse(json)?.AsObject();
Assert.NotNull(node);
Assert.Equal(OrchestratorEventKinds.ScannerScanFailed, node["kind"]?.GetValue<string>());
Assert.Equal(JobEngineEventKinds.ScannerScanFailed, node["kind"]?.GetValue<string>());
var payload = node["payload"]?.AsObject();
Assert.NotNull(payload);
@@ -225,10 +225,10 @@ public sealed class NotifierIngestionTests
[Fact]
public void VulnerabilityDetectedEvent_SerializesForNotifier()
{
var orchestratorEvent = new OrchestratorEvent
var jobEngineEvent = new JobEngineEvent
{
EventId = Guid.NewGuid(),
Kind = OrchestratorEventKinds.ScannerVulnerabilityDetected,
Kind = JobEngineEventKinds.ScannerVulnerabilityDetected,
Version = 1,
Tenant = "test-tenant",
OccurredAt = DateTimeOffset.Parse("2025-12-07T10:00:00Z"),
@@ -270,11 +270,11 @@ public sealed class NotifierIngestionTests
}
};
var json = OrchestratorEventSerializer.Serialize(orchestratorEvent);
var json = JobEngineEventSerializer.Serialize(jobEngineEvent);
var node = JsonNode.Parse(json)?.AsObject();
Assert.NotNull(node);
Assert.Equal(OrchestratorEventKinds.ScannerVulnerabilityDetected, node["kind"]?.GetValue<string>());
Assert.Equal(JobEngineEventKinds.ScannerVulnerabilityDetected, node["kind"]?.GetValue<string>());
var payload = node["payload"]?.AsObject();
Assert.NotNull(payload);
@@ -297,10 +297,10 @@ public sealed class NotifierIngestionTests
[Fact]
public void SbomGeneratedEvent_SerializesForNotifier()
{
var orchestratorEvent = new OrchestratorEvent
var jobEngineEvent = new JobEngineEvent
{
EventId = Guid.NewGuid(),
Kind = OrchestratorEventKinds.ScannerSbomGenerated,
Kind = JobEngineEventKinds.ScannerSbomGenerated,
Version = 1,
Tenant = "test-tenant",
OccurredAt = DateTimeOffset.Parse("2025-12-07T10:00:00Z"),
@@ -331,11 +331,11 @@ public sealed class NotifierIngestionTests
}
};
var json = OrchestratorEventSerializer.Serialize(orchestratorEvent);
var json = JobEngineEventSerializer.Serialize(jobEngineEvent);
var node = JsonNode.Parse(json)?.AsObject();
Assert.NotNull(node);
Assert.Equal(OrchestratorEventKinds.ScannerSbomGenerated, node["kind"]?.GetValue<string>());
Assert.Equal(JobEngineEventKinds.ScannerSbomGenerated, node["kind"]?.GetValue<string>());
var payload = node["payload"]?.AsObject();
Assert.NotNull(payload);
@@ -349,12 +349,12 @@ public sealed class NotifierIngestionTests
[Fact]
public void AllEventKinds_HaveCorrectFormat()
{
Assert.Matches(@"^scanner\.event\.[a-z]+\.[a-z]+$", OrchestratorEventKinds.ScannerReportReady);
Assert.Matches(@"^scanner\.event\.[a-z]+\.[a-z]+$", OrchestratorEventKinds.ScannerScanCompleted);
Assert.Matches(@"^scanner\.event\.[a-z]+\.[a-z]+$", OrchestratorEventKinds.ScannerScanStarted);
Assert.Matches(@"^scanner\.event\.[a-z]+\.[a-z]+$", OrchestratorEventKinds.ScannerScanFailed);
Assert.Matches(@"^scanner\.event\.[a-z]+\.[a-z]+$", OrchestratorEventKinds.ScannerSbomGenerated);
Assert.Matches(@"^scanner\.event\.[a-z]+\.[a-z]+$", OrchestratorEventKinds.ScannerVulnerabilityDetected);
Assert.Matches(@"^scanner\.event\.[a-z]+\.[a-z]+$", JobEngineEventKinds.ScannerReportReady);
Assert.Matches(@"^scanner\.event\.[a-z]+\.[a-z]+$", JobEngineEventKinds.ScannerScanCompleted);
Assert.Matches(@"^scanner\.event\.[a-z]+\.[a-z]+$", JobEngineEventKinds.ScannerScanStarted);
Assert.Matches(@"^scanner\.event\.[a-z]+\.[a-z]+$", JobEngineEventKinds.ScannerScanFailed);
Assert.Matches(@"^scanner\.event\.[a-z]+\.[a-z]+$", JobEngineEventKinds.ScannerSbomGenerated);
Assert.Matches(@"^scanner\.event\.[a-z]+\.[a-z]+$", JobEngineEventKinds.ScannerVulnerabilityDetected);
}
[Trait("Category", TestCategories.Unit)]
@@ -373,8 +373,8 @@ public sealed class NotifierIngestionTests
ImmediateDispatch = false
};
var orchestratorEvent = CreateTestEvent(metadata);
var json = OrchestratorEventSerializer.Serialize(orchestratorEvent);
var jobEngineEvent = CreateTestEvent(metadata);
var json = JobEngineEventSerializer.Serialize(jobEngineEvent);
var node = JsonNode.Parse(json)?.AsObject();
Assert.NotNull(node);
@@ -386,12 +386,12 @@ public sealed class NotifierIngestionTests
}
}
private static OrchestratorEvent CreateTestEvent(NotifierIngestionMetadata? notifier)
private static JobEngineEvent CreateTestEvent(NotifierIngestionMetadata? notifier)
{
return new OrchestratorEvent
return new JobEngineEvent
{
EventId = Guid.NewGuid(),
Kind = OrchestratorEventKinds.ScannerReportReady,
Kind = JobEngineEventKinds.ScannerReportReady,
Version = 1,
Tenant = "test-tenant",
OccurredAt = DateTimeOffset.UtcNow,

View File

@@ -23,26 +23,26 @@ public sealed class PlatformEventSamplesTests
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData("scanner.event.report.ready@1.sample.json", OrchestratorEventKinds.ScannerReportReady)]
[InlineData("scanner.event.scan.completed@1.sample.json", OrchestratorEventKinds.ScannerScanCompleted)]
[InlineData("scanner.event.report.ready@1.sample.json", JobEngineEventKinds.ScannerReportReady)]
[InlineData("scanner.event.scan.completed@1.sample.json", JobEngineEventKinds.ScannerScanCompleted)]
public void PlatformEventSamplesStayCanonical(string fileName, string expectedKind)
{
var json = LoadSample(fileName);
var orchestratorEvent = DeserializeOrchestratorEvent(json, expectedKind);
var jobEngineEvent = DeserializeJobEngineEvent(json, expectedKind);
Assert.NotNull(orchestratorEvent);
Assert.Equal(expectedKind, orchestratorEvent.Kind);
Assert.Equal(1, orchestratorEvent.Version);
Assert.NotEqual(Guid.Empty, orchestratorEvent.EventId);
Assert.NotNull(orchestratorEvent.Payload);
Assert.NotNull(jobEngineEvent);
Assert.Equal(expectedKind, jobEngineEvent.Kind);
Assert.Equal(1, jobEngineEvent.Version);
Assert.NotEqual(Guid.Empty, jobEngineEvent.EventId);
Assert.NotNull(jobEngineEvent.Payload);
AssertReportConsistency(orchestratorEvent);
AssertSemanticEquality(json, orchestratorEvent);
AssertReportConsistency(jobEngineEvent);
AssertSemanticEquality(json, jobEngineEvent);
}
private static void AssertSemanticEquality(string originalJson, OrchestratorEvent orchestratorEvent)
private static void AssertSemanticEquality(string originalJson, JobEngineEvent jobEngineEvent)
{
var canonicalJson = OrchestratorEventSerializer.Serialize(orchestratorEvent);
var canonicalJson = JobEngineEventSerializer.Serialize(jobEngineEvent);
var originalNode = JsonNode.Parse(originalJson) ?? throw new InvalidOperationException("Sample JSON must not be null.");
var canonicalNode = JsonNode.Parse(canonicalJson) ?? throw new InvalidOperationException("Canonical JSON must not be null.");
@@ -101,9 +101,9 @@ public sealed class PlatformEventSamplesTests
return a.ToJsonString() == b.ToJsonString();
}
private static void AssertReportConsistency(OrchestratorEvent orchestratorEvent)
private static void AssertReportConsistency(JobEngineEvent jobEngineEvent)
{
switch (orchestratorEvent.Payload)
switch (jobEngineEvent.Payload)
{
case ReportReadyEventPayload ready:
Assert.Equal(ready.ReportId, ready.Report.ReportId);
@@ -143,7 +143,7 @@ public sealed class PlatformEventSamplesTests
}
break;
default:
throw new InvalidOperationException($"Unexpected payload type {orchestratorEvent.Payload.GetType().Name}.");
throw new InvalidOperationException($"Unexpected payload type {jobEngineEvent.Payload.GetType().Name}.");
}
}
@@ -160,7 +160,7 @@ public sealed class PlatformEventSamplesTests
Assert.Equal(report.Verdict, dsseReport.Verdict);
}
private static OrchestratorEvent DeserializeOrchestratorEvent(string json, string expectedKind)
private static JobEngineEvent DeserializeJobEngineEvent(string json, string expectedKind)
{
var root = JsonNode.Parse(json)?.AsObject() ?? throw new InvalidOperationException("Sample JSON must not be null.");
@@ -171,10 +171,10 @@ public sealed class PlatformEventSamplesTests
StringComparer.Ordinal).ToImmutableSortedDictionary(StringComparer.Ordinal)
: null;
OrchestratorEventScope? scope = null;
JobEngineEventScope? scope = null;
if (root["scope"] is JsonObject scopeObj)
{
scope = new OrchestratorEventScope
scope = new JobEngineEventScope
{
Namespace = scopeObj["namespace"]?.GetValue<string>(),
Repo = scopeObj["repo"]?.GetValue<string>() ?? string.Empty,
@@ -185,11 +185,11 @@ public sealed class PlatformEventSamplesTests
}
var payloadNode = root["payload"] ?? throw new InvalidOperationException("Payload node missing.");
OrchestratorEventPayload payload = expectedKind switch
JobEngineEventPayload payload = expectedKind switch
{
OrchestratorEventKinds.ScannerReportReady => payloadNode.Deserialize<ReportReadyEventPayload>(SerializerOptions)
JobEngineEventKinds.ScannerReportReady => payloadNode.Deserialize<ReportReadyEventPayload>(SerializerOptions)
?? throw new InvalidOperationException("Unable to deserialize report ready payload."),
OrchestratorEventKinds.ScannerScanCompleted => payloadNode.Deserialize<ScanCompletedEventPayload>(SerializerOptions)
JobEngineEventKinds.ScannerScanCompleted => payloadNode.Deserialize<ScanCompletedEventPayload>(SerializerOptions)
?? throw new InvalidOperationException("Unable to deserialize scan completed payload."),
_ => throw new InvalidOperationException("Unexpected event kind.")
};
@@ -204,7 +204,7 @@ public sealed class PlatformEventSamplesTests
throw new InvalidOperationException("ReportId was not parsed from scan completed payload.");
}
return new OrchestratorEvent
return new JobEngineEvent
{
EventId = Guid.Parse(root["eventId"]!.GetValue<string>()),
Kind = root["kind"]!.GetValue<string>(),

View File

@@ -133,7 +133,7 @@ public sealed class ReportEventDispatcherTests
Assert.Equal(2, publisher.Events.Count);
var readyEvent = Assert.Single(publisher.Events, evt => evt.Kind == OrchestratorEventKinds.ScannerReportReady);
var readyEvent = Assert.Single(publisher.Events, evt => evt.Kind == JobEngineEventKinds.ScannerReportReady);
Assert.Equal("tenant-alpha", readyEvent.Tenant);
Assert.Equal("scanner.event.report.ready:tenant-alpha:report-abc", readyEvent.IdempotencyKey);
Assert.Equal("api", readyEvent.Scope?.Repo);
@@ -156,7 +156,7 @@ public sealed class ReportEventDispatcherTests
Assert.Equal(envelope.Payload, readyPayload.Dsse?.Payload);
Assert.Equal("blocked", readyPayload.Report.Verdict);
var scanEvent = Assert.Single(publisher.Events, evt => evt.Kind == OrchestratorEventKinds.ScannerScanCompleted);
var scanEvent = Assert.Single(publisher.Events, evt => evt.Kind == JobEngineEventKinds.ScannerScanCompleted);
Assert.Equal("tenant-alpha", scanEvent.Tenant);
Assert.Equal("scanner.event.scan.completed:tenant-alpha:report-abc", scanEvent.IdempotencyKey);
Assert.Equal("sha256:feedface", scanEvent.Scope?.Digest);
@@ -446,7 +446,7 @@ public sealed class ReportEventDispatcherTests
await dispatcher.PublishAsync(request, preview, document, envelope, context, cancellationToken);
var readyEvent = Assert.Single(publisher.Events, evt => evt.Kind == OrchestratorEventKinds.ScannerReportReady);
var readyEvent = Assert.Single(publisher.Events, evt => evt.Kind == JobEngineEventKinds.ScannerReportReady);
var links = Assert.IsType<ReportReadyEventPayload>(readyEvent.Payload).Links;
Assert.Equal("https://scanner.example/console/insights/report-abc", links.Report?.Ui);
@@ -459,9 +459,9 @@ public sealed class ReportEventDispatcherTests
private sealed class RecordingEventPublisher : IPlatformEventPublisher
{
public List<OrchestratorEvent> Events { get; } = new();
public List<JobEngineEvent> Events { get; } = new();
public Task PublishAsync(OrchestratorEvent @event, CancellationToken cancellationToken = default)
public Task PublishAsync(JobEngineEvent @event, CancellationToken cancellationToken = default)
{
Events.Add(@event);
return Task.CompletedTask;

View File

@@ -210,8 +210,8 @@ rules:
response.EnsureSuccessStatusCode();
Assert.Equal(2, recorder.Events.Count);
var ready = recorder.Events.Single(evt => evt.Kind == OrchestratorEventKinds.ScannerReportReady);
var completed = recorder.Events.Single(evt => evt.Kind == OrchestratorEventKinds.ScannerScanCompleted);
var ready = recorder.Events.Single(evt => evt.Kind == JobEngineEventKinds.ScannerReportReady);
var completed = recorder.Events.Single(evt => evt.Kind == JobEngineEventKinds.ScannerScanCompleted);
Assert.Equal("default", ready.Tenant);
Assert.Equal("default", completed.Tenant);
@@ -255,9 +255,9 @@ rules:
private sealed class RecordingPlatformEventPublisher : IPlatformEventPublisher
{
public List<OrchestratorEvent> Events { get; } = new();
public List<JobEngineEvent> Events { get; } = new();
public Task PublishAsync(OrchestratorEvent @event, CancellationToken cancellationToken = default)
public Task PublishAsync(JobEngineEvent @event, CancellationToken cancellationToken = default)
{
Events.Add(@event);
return Task.CompletedTask;

View File

@@ -1,24 +1,17 @@
// =============================================================================
// ScoreReplayEndpointsTests.cs
// Sprint: SPRINT_3401_0002_0001_score_replay_proof_bundle
// Task: SCORE-REPLAY-013 - Integration tests for score replay endpoint
// Sprint: SPRINT_20260304_303_Scanner_score_replay_contract_and_formula_alignment
// Description: Integration tests for score replay endpoint contracts and deterministic metadata.
// =============================================================================
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace StellaOps.Scanner.WebService.Tests;
/// <summary>
/// Integration tests for score replay endpoints.
/// Per Sprint 3401.0002.0001 - Score Replay & Proof Bundle.
/// </summary>
[Trait("Category", "Integration")]
[Trait("Sprint", "3401.0002")]
public sealed class ScoreReplayEndpointsTests : IAsyncLifetime
{
private TestSurfaceSecretsScope _secrets = null!;
@@ -44,287 +37,256 @@ public sealed class ScoreReplayEndpointsTests : IAsyncLifetime
_secrets.Dispose();
}
#region POST /score/{scanId}/replay Tests
[Fact(DisplayName = "POST /score/{scanId}/replay returns 404 for unknown scan")]
public async Task ReplayScore_UnknownScan_Returns404()
[Fact]
public async Task ReplayScore_UnknownScan_PrimaryRoute_Returns404()
{
// Arrange
var unknownScanId = Guid.NewGuid().ToString();
var unknownScanId = Guid.NewGuid().ToString("N");
// Act
var response = await _client.PostAsync($"/api/v1/score/{unknownScanId}/replay", null);
var response = await _client.PostAsync(
$"/api/v1/scans/{unknownScanId}/score/replay",
content: null,
TestContext.Current.CancellationToken);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact(DisplayName = "POST /score/{scanId}/replay returns result for valid scan")]
public async Task ReplayScore_ValidScan_ReturnsResult()
[Fact]
public async Task ReplayScore_PrimaryRoute_ReturnsFactorizedContract()
{
// Arrange
var scanId = await CreateTestScanAsync();
// Act
var response = await _client.PostAsync($"/api/v1/score/{scanId}/replay", null);
var replay = await ReplayAsync(scanId, useLegacyRoute: false);
replay.Score.Should().BeInRange(0.0, 1.0);
replay.RootHash.Should().StartWith("sha256:");
replay.BundleUri.Should().NotBeNullOrWhiteSpace();
replay.ManifestHash.Should().StartWith("sha256:");
replay.ManifestDigest.Should().StartWith("sha256:");
replay.CanonicalInputHash.Should().StartWith("sha256:");
replay.CanonicalInputPayload.Should().NotBeNullOrWhiteSpace();
replay.SeedHex.Should().NotBeNullOrWhiteSpace();
replay.VerificationStatus.Should().Be("verified");
replay.Deterministic.Should().BeTrue();
replay.Factors.Should().NotBeNullOrEmpty();
replay.Factors.Should().OnlyContain(f => !string.IsNullOrWhiteSpace(f.Name));
replay.Factors.Should().OnlyContain(f => f.Weight >= 0 && f.Weight <= 1);
}
[Fact]
public async Task ReplayScore_PrimaryAndLegacyRoutes_AreCompatibleAndDeterministic()
{
var scanId = await CreateTestScanAsync();
var primary = await ReplayAsync(scanId, useLegacyRoute: false);
var legacy = await ReplayAsync(scanId, useLegacyRoute: true);
primary.Score.Should().Be(legacy.Score);
primary.RootHash.Should().Be(legacy.RootHash);
primary.CanonicalInputHash.Should().Be(legacy.CanonicalInputHash);
primary.ManifestDigest.Should().Be(legacy.ManifestDigest);
}
[Fact]
public async Task GetBundle_PrimaryAndLegacyRoutes_ReturnSameBundle()
{
var scanId = await CreateTestScanAsync();
var replay = await ReplayAsync(scanId, useLegacyRoute: false);
var primaryResponse = await _client.GetFromJsonAsync<ScoreBundleResponse>(
$"/api/v1/scans/{scanId}/score/bundle",
TestContext.Current.CancellationToken);
var legacyResponse = await _client.GetFromJsonAsync<ScoreBundleResponse>(
$"/api/v1/score/{scanId}/bundle",
TestContext.Current.CancellationToken);
primaryResponse.Should().NotBeNull();
legacyResponse.Should().NotBeNull();
primaryResponse!.RootHash.Should().Be(replay.RootHash);
primaryResponse.RootHash.Should().Be(legacyResponse!.RootHash);
primaryResponse.ManifestDsseValid.Should().BeTrue();
legacyResponse.ManifestDsseValid.Should().BeTrue();
}
[Fact]
public async Task VerifyBundle_WrongCanonicalHash_ReturnsInvalid()
{
var scanId = await CreateTestScanAsync();
var replay = await ReplayAsync(scanId, useLegacyRoute: false);
var response = await _client.PostAsJsonAsync(
$"/api/v1/scans/{scanId}/score/verify",
new ScoreVerifyRequest(
ExpectedRootHash: replay.RootHash,
ExpectedCanonicalInputHash: "sha256:deadbeef"),
TestContext.Current.CancellationToken);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var verify = await response.Content.ReadFromJsonAsync<ScoreVerifyResponse>(
cancellationToken: TestContext.Current.CancellationToken);
var result = await response.Content.ReadFromJsonAsync<ScoreReplayResponse>();
verify.Should().NotBeNull();
verify!.Valid.Should().BeFalse();
verify.ComputedRootHash.Should().Be(replay.RootHash);
verify.CanonicalInputHashValid.Should().BeFalse();
verify.ExpectedCanonicalInputHash.Should().Be("sha256:deadbeef");
verify.ErrorMessage.Should().NotBeNullOrWhiteSpace();
}
[Fact]
public async Task VerifyBundle_TamperedCanonicalPayload_ReturnsInvalid()
{
var scanId = await CreateTestScanAsync();
var replay = await ReplayAsync(scanId, useLegacyRoute: false);
var response = await _client.PostAsJsonAsync(
$"/api/v1/scans/{scanId}/score/verify",
new ScoreVerifyRequest(
ExpectedRootHash: replay.RootHash,
CanonicalInputPayload: replay.CanonicalInputPayload + " "),
TestContext.Current.CancellationToken);
response.StatusCode.Should().Be(HttpStatusCode.OK);
var verify = await response.Content.ReadFromJsonAsync<ScoreVerifyResponse>(
cancellationToken: TestContext.Current.CancellationToken);
verify.Should().NotBeNull();
verify!.Valid.Should().BeFalse();
verify.CanonicalInputHashValid.Should().BeFalse();
verify.CanonicalInputHash.Should().NotBe(replay.CanonicalInputHash);
verify.ErrorMessage.Should().NotBeNullOrWhiteSpace();
}
[Fact]
public async Task ScoreHistory_PrimaryAndLegacyRoutes_ExposeFactorVectors()
{
var scanId = await CreateTestScanAsync();
var replay1 = await ReplayAsync(scanId, useLegacyRoute: false);
var replay2 = await ReplayAsync(
scanId,
useLegacyRoute: false,
request: new ScoreReplayRequest(FreezeTimestamp: DateTimeOffset.UtcNow.AddMinutes(7)));
replay1.RootHash.Should().NotBe(replay2.RootHash);
var primaryHistory = await _client.GetFromJsonAsync<List<ScoreHistoryResponseItem>>(
$"/api/v1/scans/{scanId}/score/history",
TestContext.Current.CancellationToken);
var legacyHistory = await _client.GetFromJsonAsync<List<ScoreHistoryResponseItem>>(
$"/api/v1/score/{scanId}/history",
TestContext.Current.CancellationToken);
primaryHistory.Should().NotBeNull();
legacyHistory.Should().NotBeNull();
primaryHistory!.Should().Contain(h => h.RootHash == replay1.RootHash);
primaryHistory.Should().Contain(h => h.RootHash == replay2.RootHash);
primaryHistory.Should().OnlyContain(h => h.CanonicalInputHash.StartsWith("sha256:", StringComparison.Ordinal));
primaryHistory.Should().OnlyContain(h => h.ManifestDigest.StartsWith("sha256:", StringComparison.Ordinal));
primaryHistory.Should().OnlyContain(h => h.Factors.Count > 0);
primaryHistory.Should().BeInDescendingOrder(h => h.ReplayedAt);
legacyHistory!.Select(h => h.RootHash).Should().BeEquivalentTo(primaryHistory.Select(h => h.RootHash));
}
private async Task<ScoreReplayResponse> ReplayAsync(
string scanId,
bool useLegacyRoute,
ScoreReplayRequest? request = null)
{
var route = useLegacyRoute
? $"/api/v1/score/{scanId}/replay"
: $"/api/v1/scans/{scanId}/score/replay";
HttpResponseMessage response;
if (request is null)
{
response = await _client.PostAsync(route, null, TestContext.Current.CancellationToken);
}
else
{
response = await _client.PostAsJsonAsync(route, request, TestContext.Current.CancellationToken);
}
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<ScoreReplayResponse>(
cancellationToken: TestContext.Current.CancellationToken);
result.Should().NotBeNull();
result!.Score.Should().BeInRange(0.0, 1.0);
result.RootHash.Should().StartWith("sha256:");
result.BundleUri.Should().NotBeNullOrEmpty();
result.Deterministic.Should().BeTrue();
return result!;
}
[Fact(DisplayName = "POST /score/{scanId}/replay is deterministic")]
public async Task ReplayScore_IsDeterministic()
{
// Arrange
var scanId = await CreateTestScanAsync();
// Act - replay twice
var response1 = await _client.PostAsync($"/api/v1/score/{scanId}/replay", null);
var response2 = await _client.PostAsync($"/api/v1/score/{scanId}/replay", null);
// Assert
response1.StatusCode.Should().Be(HttpStatusCode.OK);
response2.StatusCode.Should().Be(HttpStatusCode.OK);
var result1 = await response1.Content.ReadFromJsonAsync<ScoreReplayResponse>();
var result2 = await response2.Content.ReadFromJsonAsync<ScoreReplayResponse>();
result1!.Score.Should().Be(result2!.Score, "Score should be deterministic");
result1.RootHash.Should().Be(result2.RootHash, "RootHash should be deterministic");
}
[Fact(DisplayName = "POST /score/{scanId}/replay with specific manifest hash")]
public async Task ReplayScore_WithManifestHash_UsesSpecificManifest()
{
// Arrange
var scanId = await CreateTestScanAsync();
// Get the manifest hash from the first replay
var firstResponse = await _client.PostAsync($"/api/v1/score/{scanId}/replay", null);
var firstResult = await firstResponse.Content.ReadFromJsonAsync<ScoreReplayResponse>();
var manifestHash = firstResult!.ManifestHash;
// Act - replay with specific manifest hash
var response = await _client.PostAsJsonAsync(
$"/api/v1/score/{scanId}/replay",
new { manifestHash });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<ScoreReplayResponse>();
result!.ManifestHash.Should().Be(manifestHash);
}
#endregion
#region GET /score/{scanId}/bundle Tests
[Fact(DisplayName = "GET /score/{scanId}/bundle returns 404 for unknown scan")]
public async Task GetBundle_UnknownScan_Returns404()
{
// Arrange
var unknownScanId = Guid.NewGuid().ToString();
// Act
var response = await _client.GetAsync($"/api/v1/score/{unknownScanId}/bundle");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact(DisplayName = "GET /score/{scanId}/bundle returns bundle after replay")]
public async Task GetBundle_AfterReplay_ReturnsBundle()
{
// Arrange
var scanId = await CreateTestScanAsync();
// Create a replay first
var replayResponse = await _client.PostAsync($"/api/v1/score/{scanId}/replay", null);
replayResponse.EnsureSuccessStatusCode();
var replayResult = await replayResponse.Content.ReadFromJsonAsync<ScoreReplayResponse>();
// Act
var response = await _client.GetAsync($"/api/v1/score/{scanId}/bundle");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var bundle = await response.Content.ReadFromJsonAsync<ProofBundleResponse>();
bundle.Should().NotBeNull();
bundle!.RootHash.Should().Be(replayResult!.RootHash);
bundle.ManifestDsseValid.Should().BeTrue();
}
[Fact(DisplayName = "GET /score/{scanId}/bundle with specific rootHash")]
public async Task GetBundle_WithRootHash_ReturnsSpecificBundle()
{
// Arrange
var scanId = await CreateTestScanAsync();
// Create a replay to get a root hash
var replayResponse = await _client.PostAsync($"/api/v1/score/{scanId}/replay", null);
var replayResult = await replayResponse.Content.ReadFromJsonAsync<ScoreReplayResponse>();
var rootHash = replayResult!.RootHash;
// Act
var response = await _client.GetAsync($"/api/v1/score/{scanId}/bundle?rootHash={rootHash}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var bundle = await response.Content.ReadFromJsonAsync<ProofBundleResponse>();
bundle!.RootHash.Should().Be(rootHash);
}
#endregion
#region POST /score/{scanId}/verify Tests
[Fact(DisplayName = "POST /score/{scanId}/verify returns valid for correct root hash")]
public async Task VerifyBundle_CorrectRootHash_ReturnsValid()
{
// Arrange
var scanId = await CreateTestScanAsync();
// Create a replay
var replayResponse = await _client.PostAsync($"/api/v1/score/{scanId}/replay", null);
var replayResult = await replayResponse.Content.ReadFromJsonAsync<ScoreReplayResponse>();
// Act
var response = await _client.PostAsJsonAsync(
$"/api/v1/score/{scanId}/verify",
new { expectedRootHash = replayResult!.RootHash });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<BundleVerifyResponse>();
result!.Valid.Should().BeTrue();
result.ComputedRootHash.Should().Be(replayResult.RootHash);
}
[Fact(DisplayName = "POST /score/{scanId}/verify returns invalid for wrong root hash")]
public async Task VerifyBundle_WrongRootHash_ReturnsInvalid()
{
// Arrange
var scanId = await CreateTestScanAsync();
// Create a replay first
await _client.PostAsync($"/api/v1/score/{scanId}/replay", null);
// Act
var response = await _client.PostAsJsonAsync(
$"/api/v1/score/{scanId}/verify",
new { expectedRootHash = "sha256:wrong_hash_value" });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<BundleVerifyResponse>();
result!.Valid.Should().BeFalse();
}
[Fact(DisplayName = "POST /score/{scanId}/verify validates manifest signature")]
public async Task VerifyBundle_ValidatesManifestSignature()
{
// Arrange
var scanId = await CreateTestScanAsync();
// Create a replay
var replayResponse = await _client.PostAsync($"/api/v1/score/{scanId}/replay", null);
var replayResult = await replayResponse.Content.ReadFromJsonAsync<ScoreReplayResponse>();
// Act
var response = await _client.PostAsJsonAsync(
$"/api/v1/score/{scanId}/verify",
new { expectedRootHash = replayResult!.RootHash });
// Assert
var result = await response.Content.ReadFromJsonAsync<BundleVerifyResponse>();
result!.ManifestValid.Should().BeTrue();
}
#endregion
#region Concurrency Tests
[Fact(DisplayName = "Concurrent replays produce same result")]
public async Task ConcurrentReplays_ProduceSameResult()
{
// Arrange
var scanId = await CreateTestScanAsync();
// Act - concurrent replays
var tasks = Enumerable.Range(0, 5)
.Select(_ => _client.PostAsync($"/api/v1/score/{scanId}/replay", null))
.ToList();
var responses = await Task.WhenAll(tasks);
// Assert
var results = new List<ScoreReplayResponse>();
foreach (var response in responses)
{
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<ScoreReplayResponse>();
results.Add(result!);
}
// All results should have the same score and root hash
var firstResult = results[0];
foreach (var result in results.Skip(1))
{
result.Score.Should().Be(firstResult.Score);
result.RootHash.Should().Be(firstResult.RootHash);
}
}
#endregion
#region Helper Methods
private async Task<string> CreateTestScanAsync()
{
var submitResponse = await _client.PostAsJsonAsync("/api/v1/scans", new
{
image = new { digest = "sha256:test_" + Guid.NewGuid().ToString("N")[..8] }
});
image = new { digest = "sha256:test_" + Guid.NewGuid().ToString("N")[..12] }
}, TestContext.Current.CancellationToken);
submitResponse.EnsureSuccessStatusCode();
var submitPayload = await submitResponse.Content.ReadFromJsonAsync<ScanSubmitResponse>();
var submitPayload = await submitResponse.Content.ReadFromJsonAsync<ScanSubmitResponse>(
cancellationToken: TestContext.Current.CancellationToken);
submitPayload.Should().NotBeNull();
return submitPayload!.ScanId;
}
#endregion
private sealed record ScoreReplayRequest(
string? ManifestHash = null,
DateTimeOffset? FreezeTimestamp = null);
#region Response Models
private sealed record ScoreReplayFactor(
string Name,
double Weight,
double Raw,
double Weighted,
string Source);
private sealed record ScoreReplayResponse(
double Score,
string RootHash,
string BundleUri,
string ManifestHash,
string ManifestDigest,
string CanonicalInputHash,
string CanonicalInputPayload,
string SeedHex,
List<ScoreReplayFactor> Factors,
string VerificationStatus,
DateTimeOffset ReplayedAt,
bool Deterministic);
private sealed record ProofBundleResponse(
private sealed record ScoreBundleResponse(
string ScanId,
string RootHash,
string BundleUri,
bool ManifestDsseValid,
DateTimeOffset CreatedAt);
private sealed record BundleVerifyResponse(
private sealed record ScoreVerifyRequest(
string ExpectedRootHash,
string? BundleUri = null,
string? ExpectedCanonicalInputHash = null,
string? CanonicalInputPayload = null);
private sealed record ScoreVerifyResponse(
bool Valid,
string ComputedRootHash,
string ExpectedRootHash,
bool ManifestValid,
bool LedgerValid,
bool CanonicalInputHashValid,
string? ExpectedCanonicalInputHash,
string? CanonicalInputHash,
DateTimeOffset VerifiedAtUtc,
string? ErrorMessage);
private sealed record ScanSubmitResponse(string ScanId);
private sealed record ScoreHistoryResponseItem(
string RootHash,
DateTimeOffset ReplayedAt,
double Score,
string CanonicalInputHash,
string ManifestDigest,
List<ScoreReplayFactor> Factors);
#endregion
private sealed record ScanSubmitResponse(string ScanId);
}