consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"kind": "file",
|
||||
"source": "package.json",
|
||||
"locator": "layers/layer1/app/package.json",
|
||||
"sha256": "d846f429c41d17adeacfd418431ab4be4857b40a749eeea229d7be91644d6d5d"
|
||||
"sha256": "23abb943f062b3ccdc18966eb36dfc48dd7ec4b5a6105851484fe2911946ecdd"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
"kind": "file",
|
||||
"source": "package.json",
|
||||
"locator": "packages/nested/tool/package.json",
|
||||
"sha256": "9d7d0f85e36dbcd09eedf4d85a1a53a07f92bf768b1375f18a997ba0ee9295d9"
|
||||
"sha256": "3011f57f07fab11b4ecb61788319bc9768d2577cafd9f53f37a7cac721fc77cf"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -22,5 +22,6 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="Fixtures\*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Update="Snapshots\*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}\"";
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user