mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-28 00:32:22 +01:00
Merge current dev and refresh PR #6739 from the 7011 integration
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"digest": "9868d50a542b7ad1",
|
||||
"digest": "f7e60e4a567c46dc",
|
||||
"elements": {
|
||||
"app-shell": {
|
||||
"app-loader": "f04ad5bf6312d53f",
|
||||
"attach-strip": "a6474951e2481214",
|
||||
"body": "58df17f0afa190b9",
|
||||
"body": "4956f449e6a30e93",
|
||||
"chat-container": "b00a7c6be4e107b1",
|
||||
"chat-context-pill": "dd905b87245a9a71",
|
||||
"chat-history": "e65bdc51f4d1675f",
|
||||
@@ -23,8 +23,8 @@
|
||||
"input-icon-btn": "3d1c773acd84a825",
|
||||
"memory-close-btn": "c3f4ba99098a6e4b",
|
||||
"memory-list": "da9a37a3de3c1a31",
|
||||
"memory-modal": "5efc81537eab4526",
|
||||
"memory-modal-content": "3cadc8581361436e",
|
||||
"memory-modal": "e2f97b123dc9bc55",
|
||||
"memory-modal-content": "5c5fe055c711e642",
|
||||
"memory-modal-header": "a8c822fe8c6d20e7",
|
||||
"memory-search-input": "8b0fbb33401750d4",
|
||||
"memory-toolbar-btn": "9f985efa7c896f90",
|
||||
@@ -42,7 +42,7 @@
|
||||
"rail-new-chat": "f8f1c2f6be426ab9",
|
||||
"reasoning-effort-btn": "2a07cdd8ec2b4d26",
|
||||
"rename-session-modal": "9345624780009d14",
|
||||
"root": "820190c144fda419",
|
||||
"root": "a9f7ace2c1cefb24",
|
||||
"save-custom-preset": "443da70ef5fe9b88",
|
||||
"scroll-bottom-btn": "24602d72e2949991",
|
||||
"search-input": "53b94f3e5fdb384d",
|
||||
@@ -54,7 +54,7 @@
|
||||
"settings-btn-sm": "c31bcb9bdfbae10d",
|
||||
"settings-fallbacks": "e339280590935fe3",
|
||||
"settings-logs-tab": "e470140373597143",
|
||||
"settings-modal": "5efc81537eab4526",
|
||||
"settings-modal": "e2f97b123dc9bc55",
|
||||
"settings-nav-search": "f4484b2980f2ccc5",
|
||||
"settings-row": "de568724268cec6e",
|
||||
"settings-select": "a96e8430b0ec6668",
|
||||
@@ -69,7 +69,7 @@
|
||||
"theme-grid": "4d4b2aeb8952cc6f",
|
||||
"theme-io-btn": "859f222c79a8c721",
|
||||
"theme-modal": "0a65ca916656cdbb",
|
||||
"theme-popup": "3aa65e6f313bbc65",
|
||||
"theme-popup": "fb86cc5f81d0aaa6",
|
||||
"theme-tabs": "0cc4987f39876dce",
|
||||
"toast": "c60c6a60d4982005",
|
||||
"tool-indicator": "0dee45eaca8b703a",
|
||||
@@ -83,7 +83,7 @@
|
||||
"#agent-indicator": "09a1b8d94b1487ee",
|
||||
"#agent-indicator.active": "67e2ad3fa48acd3b",
|
||||
"#compare-model-overlay .modal-body": "0b1ee357313551b9",
|
||||
"#compare-model-overlay .modal-content": "56883147d181bd9c",
|
||||
"#compare-model-overlay .modal-content": "9b2145292f439a88",
|
||||
"#cookbook-modal .cookbook-body": "20bd8a7268cee788",
|
||||
"#cookbook-modal .hwfit-cached-list.cookbook-serve-models-just-loaded > .doclib-card": "48f6787a3df3f19e",
|
||||
"#cookbook-modal .modal-content": "0c4e1e686df0990e",
|
||||
@@ -101,7 +101,7 @@
|
||||
"#email-lib-modal .email-lib-sync-status": "6d1a0374a16cf7f4",
|
||||
"#email-lib-modal .email-reader-atts": "c5d68ecda3c14bb8",
|
||||
"#email-lib-modal .email-reader-header": "85afca11c8cbd38e",
|
||||
"#email-lib-modal .modal-content": "d8b8fff38c613c95",
|
||||
"#email-lib-modal .modal-content": "262753f287410717",
|
||||
"#email-lib-settings-btn": "9ae15bf04b6a22ce",
|
||||
"#email-unread-dot.sidebar-notif-dot": "c80f1e03592983b6",
|
||||
"#export-compact-btn": "7d0325a38e51134b",
|
||||
@@ -686,44 +686,44 @@
|
||||
},
|
||||
"variants": {
|
||||
"app-shell": {
|
||||
"desktop-dark-comfortable": "65b23a35b5a34126",
|
||||
"desktop-dark-compact": "1bac3a0aa4467555",
|
||||
"desktop-dark-spacious": "944583b1854fecbb",
|
||||
"desktop-light-comfortable": "d279432f8c120b58",
|
||||
"desktop-light-compact": "aa768c34ba3c7abc",
|
||||
"desktop-light-spacious": "b74f53e95bfa1d82",
|
||||
"laptop-dark-comfortable": "d98d16f3d60ed275",
|
||||
"laptop-dark-compact": "c3eaf3c4278a0120",
|
||||
"laptop-dark-spacious": "a085fe0f7d9eb66c",
|
||||
"laptop-light-comfortable": "0d7c64f324bebe87",
|
||||
"laptop-light-compact": "da50bf03b0f1be58",
|
||||
"laptop-light-spacious": "a6789fd05a66bf5a",
|
||||
"phone-dark-comfortable": "21b94f88da549eb5",
|
||||
"phone-dark-compact": "b9aa7def2b85414e",
|
||||
"phone-dark-spacious": "21b94f88da549eb5",
|
||||
"phone-light-comfortable": "4a0cf45db775ea7d",
|
||||
"phone-light-compact": "f7fbcb94d99c8730",
|
||||
"phone-light-spacious": "4a0cf45db775ea7d",
|
||||
"tablet-dark-comfortable": "e18256ce58398f11",
|
||||
"tablet-dark-compact": "3cf3e52a10313d44",
|
||||
"tablet-dark-spacious": "e18256ce58398f11",
|
||||
"tablet-light-comfortable": "c2a45155c391f26c",
|
||||
"tablet-light-compact": "9fa7dc03dd02b723",
|
||||
"tablet-light-spacious": "c2a45155c391f26c"
|
||||
"desktop-dark-comfortable": "0c9123117135e362",
|
||||
"desktop-dark-compact": "c694f5d5118ac0e2",
|
||||
"desktop-dark-spacious": "90a67613e8cfdf5d",
|
||||
"desktop-light-comfortable": "17727a9945ccc77b",
|
||||
"desktop-light-compact": "2a887c30d231e357",
|
||||
"desktop-light-spacious": "94b4666734b87625",
|
||||
"laptop-dark-comfortable": "26d334b139b653f2",
|
||||
"laptop-dark-compact": "fea30bad6824357d",
|
||||
"laptop-dark-spacious": "4fdb28deaaaec870",
|
||||
"laptop-light-comfortable": "c7fc986a7e70aac8",
|
||||
"laptop-light-compact": "147645431229ddff",
|
||||
"laptop-light-spacious": "d513c285d30c8289",
|
||||
"phone-dark-comfortable": "551761344bec8d09",
|
||||
"phone-dark-compact": "293d66518c3ec994",
|
||||
"phone-dark-spacious": "551761344bec8d09",
|
||||
"phone-light-comfortable": "cb41acd3f9af3578",
|
||||
"phone-light-compact": "dbef6e9e373e9c10",
|
||||
"phone-light-spacious": "cb41acd3f9af3578",
|
||||
"tablet-dark-comfortable": "0f682621b4ead393",
|
||||
"tablet-dark-compact": "e6f3fece5e716a2c",
|
||||
"tablet-dark-spacious": "0f682621b4ead393",
|
||||
"tablet-light-comfortable": "6e398df9ca69c58b",
|
||||
"tablet-light-compact": "dca75981c8675bfa",
|
||||
"tablet-light-spacious": "6e398df9ca69c58b"
|
||||
},
|
||||
"bench": {
|
||||
"desktop-dark-comfortable": "f9fa9d54db5ad45a",
|
||||
"desktop-dark-compact": "7603a804cb2e23a2",
|
||||
"desktop-dark-spacious": "b0c69306349fc078",
|
||||
"desktop-light-comfortable": "d3e98c4c1e90686f",
|
||||
"desktop-light-compact": "a04ae70088bc8b35",
|
||||
"desktop-light-spacious": "4aa01a125f541429",
|
||||
"laptop-dark-comfortable": "025e60ebe84e0f98",
|
||||
"laptop-dark-compact": "53d6555bb7a6b5bb",
|
||||
"laptop-dark-spacious": "c124d9ce1633d027",
|
||||
"laptop-light-comfortable": "e8328c45f5127bb0",
|
||||
"laptop-light-compact": "dc2da3534534c888",
|
||||
"laptop-light-spacious": "abe5483724c987e0",
|
||||
"desktop-dark-comfortable": "a9709cb47eec7544",
|
||||
"desktop-dark-compact": "bfab96cb72fd6faf",
|
||||
"desktop-dark-spacious": "25469178b443428a",
|
||||
"desktop-light-comfortable": "2166794d643fae4c",
|
||||
"desktop-light-compact": "81d8ab64407b1d9a",
|
||||
"desktop-light-spacious": "d6d19f33476665af",
|
||||
"laptop-dark-comfortable": "57169e64e1fb5324",
|
||||
"laptop-dark-compact": "6ac903cd8a711bcf",
|
||||
"laptop-dark-spacious": "a47535332e9c5de7",
|
||||
"laptop-light-comfortable": "1e2b381de3556fac",
|
||||
"laptop-light-compact": "6dbfb465121949c1",
|
||||
"laptop-light-spacious": "1b8bfcd4bc4ed214",
|
||||
"phone-dark-comfortable": "67ca3c78e6b9495a",
|
||||
"phone-dark-compact": "ad6f3a9f5c8d81cd",
|
||||
"phone-dark-spacious": "1e6f66e56519ba5d",
|
||||
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
# CA trust regression fixtures
|
||||
|
||||
The public certificates embedded in `tests/test_ca_trust.py` exercise the October 2026 eMudhra trust-store check. They contain no private keys. `emsign-g3.pem` and `aaa.pem` are the incident's affected trust anchors; `emsign-g1.pem` is an unaffected emSign anchor that must remain usable. `localhost-leaf.pem` is a synthetic self-signed Ed25519 server certificate with `CA:FALSE`, generated solely to reproduce hashed non-CA certificates found in Ubuntu's OpenSSL directory; its private key was discarded. The test creates temporary PEM files from these public constants. The certificate fingerprints are the factual identity of each fixture.
|
||||
|
||||
Sources: the [incident root list](https://emudhra.we-have-a-problem.com/roots.json) and the public Mozilla root certificates distributed in Debian's `ca-certificates` package.
|
||||
|
||||
`incident.json` contains the four published roots, sixteen [compromised intermediate certificates and cross-signs](https://emudhra.we-have-a-problem.com/impacted-intermediates.json), and the [fourteen published server chains](https://emudhra.we-have-a-problem.com/chains.zip), retrieved on 2026-10-21 with chains refreshed on 2026-10-23. The refresh adds the two DV G3B hosts without Certificate Transparency entries and preserves the original twelve chains. Certificates are deduplicated by SHA-256 of DER; each chain lists its leaf first. The additional cross-signed `emSign Root TLS CA - G3` issuer comes from those chains and has fingerprint `3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd`. Like the intermediates, it must not be accepted as a custom trust anchor. All data is public certificate material, with no private keys.
|
||||
|
||||
Downloaded source SHA-256 values:
|
||||
|
||||
- `roots.json`: `119f32cd81a6b5a4e3c4c2df5fe07daa90dc43cafa123a62f0394f1895d92380`
|
||||
- `impacted-intermediates.json`: `f67b3abe9b4879f4e91874ed041beccd71257f1db41c588c724ccf12aa90c59e`
|
||||
- `chains.zip`: `7b949956955fc501a30f4d98d2f513d34c0bbbe92e29a968194c96e097ce2345`
|
||||
|
||||
The offline OpenSSL regression verifies server purpose, hostname and strict certificate rules with partial-chain trust, at `2026-10-23T12:00:00Z` (`1792756800`). Fixing the verification date keeps later certificate expiry from masking a broken mitigation. All fourteen chains must verify against the affected positive-control roots and fail against the unaffected G1-only store. Adding the compromised DV G3B intermediate restores four paths, including the two new hosts, which the checker must reject. The regression makes no network requests and skips chain verification if the OpenSSL executable is unavailable; certificate-inventory tests still run using Python's standard library.
|
||||
|
||||
The upstream SearXNG image validation uses the same `incident.json` inventory and fixed verification timestamp, checking each stored DER fingerprint before use. See [the image documentation](../../../docs/searxng-trust-image.md) for its validation targets and source provenance.
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"verification_time": 1792756800,
|
||||
"roots": {
|
||||
"7dd78d5f4f13459a83dff9abbba62edbaf6f2d102bf257fd712f4d9f2746ed8d": "emSign Root TLS CA - G3",
|
||||
"f2edaaff3d9b701fb4d14c9f285f8c0bad90fc3bcc1fa07d47f4718edaba34a5": "emSign Root CS CA - G3",
|
||||
"d7a7a0fb5d7e2731d771e9484ebcdef71d5f0c3e0a2948782bc83ee0ea699ef4": "AAA Certificate Services",
|
||||
"86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6b": "emSign ECC Root CA - G3"
|
||||
},
|
||||
"intermediates": {
|
||||
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493": "EM DV TLS CA - G3B (eMudhra)",
|
||||
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe": "EM DV TLS CA - G3B (Sectigo)",
|
||||
"98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b": "EM OV TLS CA - G3B (eMudhra)",
|
||||
"5f1ded4dd590af74631290cf073b122a2aac6a1fa479778a0bd43579bd832699": "EM OV TLS CA - G3B (Sectigo)",
|
||||
"e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838": "EM EV TLS CA - G3B (eMudhra)",
|
||||
"579480aea166290bd3c643589e795fc0d0e54e86505d180f1dff99efd3199512": "EM EV TLS CA - G3B (Sectigo)",
|
||||
"0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19": "EM DV TLS CA - G3C (eMudhra)",
|
||||
"62b7f9868263f72f6911fc78ceaf72c1979158b7368bf648683bb2aa39b02e72": "EM DV TLS CA - G3C (Sectigo)",
|
||||
"cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f": "EM OV TLS CA - G3C (eMudhra)",
|
||||
"7485b51c0ed192878fa1d2f948fb529ae1842cc5086c4b67f7be5b2039de9489": "EM OV TLS CA - G3C (Sectigo)",
|
||||
"432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3": "EM EV TLS CA - G3C (eMudhra)",
|
||||
"fd9d5c2dbbd4995ab69b8d50c439d9527b00529199c2fd01b2174eb58f07021f": "EM EV TLS CA - G3C (Sectigo)",
|
||||
"9e39de2881273acc225a149c72ba3e0f51424963669ab633ee1dc7df5db067b4": "EM OV CS CA - G3B (eMudhra)",
|
||||
"a496e23ac0cab8155fc9b117b97eae368efcbb9041aec8804d5a308d2d01c2d8": "EM OV CS CA - G3B (Sectigo)",
|
||||
"4b2460a466f95fd40113bcdcca2ceb29b3b093921de45474da7df48874c630b1": "EM OV CS CA - G3C (eMudhra)",
|
||||
"84112972b8e59d14560ae7e7119654474aad119990cf3722bfc1e3b91615e1fd": "EM OV CS CA - G3C (Sectigo)"
|
||||
},
|
||||
"certificates": {
|
||||
"0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIPWIsuPCu4zaE+GhIJJUMhMAoGCCqGSM49BAMDMFYxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNTM1\nMDlaFw00MDA0MjgxODMwMDBaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVk\naHJhIEluYzEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAEOnp4YsjETTJN3WkDOmeCwO/u/kBV5ooSP6A3G6O9kZRRTuFy\npDPjYw8CvRiAN9nlFJ+bd9uUooHfmHsv6oiLdL3+j9pMEJnubKGVZv4+8OX2sJpV\nbMyLpQVh3xsqRB0Bo4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFK5+EFw8KZbBqEcnBVnx4D3U5QrzMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAmQwCAYGZ4EMAQIBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRFZUTFNDQUczQy5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIxAMEN2HWMocbuWdy1d33pH6JpMvP/V/DIS5PtZbv1Q7TXEbxrcyxg\nyuddOJscYDJJlAIwB4sH1BpbScDzkdD4tJBSwrG/1Mx82vb8YaySehNuu1+1oXNd\nAEKwaJ0mVzScy2+6\n-----END CERTIFICATE-----\n",
|
||||
"0cbfe22b824385823fd3bf4be4d131e8901342933a7feef1cf8b3110b8e44a70": "-----BEGIN CERTIFICATE-----\nMIICQTCCAcigAwIBAgIUG01Z/jOotSvB49K4K27kFjpdnWMwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjIxMjU3MzFaFw0yNzA1MDUxMzAyMzFa\nMDQxMjAwBgNVBAMMKWR2LWczYi1ub2N0LWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEI805yWr+RxanILifET+/\nGwHsXo7dp8ghA3OgwR0Mg11Bl6iYyOzldy4iU7kM+3qfDNsUrzxvkUkJZQpYieuC\n+aOBrDCBqTAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATA0BgNVHREELTArgilkdi1nM2Itbm9jdC1lbXVkaHJhLndlLWhh\ndmUtYS1wcm9ibGVtLmNvbTAdBgNVHQ4EFgQUOlRrLQGIczrmr4H+Oak06aUmJ4Yw\nHwYDVR0jBBgwFoAU4h4v5xwZOQ5VS2LBICRXNSSKJRQwCgYIKoZIzj0EAwIDZwAw\nZAIwf4ekmRgXAxXP2AjpVY2f1bO42GC7Nnc80t4OpaWJeVdXpT+X3dOsLRwlIUm0\nYbMwAjBihTx7oHfaYB3KRnFuhKqlHYmUcOe9Hjxqs6bubZohhrqMQzPocyasDhJL\nUx3HsgQ=\n-----END CERTIFICATE-----\n",
|
||||
"21f57f83631a93c98e867050075746b3361e0433c9e0e969462852476784f6f0": "-----BEGIN CERTIFICATE-----\nMIICPzCCAcagAwIBAgIUH0gWfBRnOiSjcduti/cukjDGAjIwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIE9WIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMjIyNDJaFw0yNzA1MDMwMjI3NDJa\nMDMxMTAvBgNVBAMMKG92LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxl\nbS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ3EfjVE+6fUm9gr7UoUv6N\nYPttIdILR9OMZnAdTj+RWT1Z4fp7dpC5mat1gbIo4J6EDelcOQQz5/oGeB3gFJKw\no4GrMIGoMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoG\nCCsGAQUFBwMBMDMGA1UdEQQsMCqCKG92LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZl\nLWEtcHJvYmxlbS5jb20wHQYDVR0OBBYEFKzQPp6gTBOWb0TBxZbkL5zsQn3oMB8G\nA1UdIwQYMBaAFJvYZVGv5+csN60I7mkZ2b83hK4KMAoGCCqGSM49BAMCA2cAMGQC\nMARKrRRBEWY2Ix1d80DGUvC7bWENP1Q9pf6BOGF4+84UiwV5+nAuht/+hdpAanWB\nsQIwJF2dW8tpPN6lTm1kDerzG6yJAJOz4drotaTthMF2+efuv5W2FL7Dr8qZSd1z\n201o\n-----END CERTIFICATE-----\n",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd": "-----BEGIN CERTIFICATE-----\nMIIDdDCCAvmgAwIBAgIQAJYaPvsTWVdu0EAFtGhKWzAKBggqhkjOPQQDAzBrMQsw\nCQYDVQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhy\nYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290\nIENBIC0gRzMwHhcNMjQwNzExMTU0MzM2WhcNMzkwNzA5MTgzMDAwWjBWMQswCQYD\nVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEg\nMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwdjAQBgcqhkjOPQIBBgUr\ngQQAIgNiAARfD9w/q1cWxL6D9bMGH6CQ4u+gAV6GN9s+PwQx9Bl4hC+0iU8QOk8m\nWO/RwXQy10DCSxT8vSIJClHKp+Ao4qJKdYeV0SwZ7LMQziyLfxIUOY6Grja/sQFw\nLKYf8EdDXAyjggF1MIIBcTAfBgNVHSMEGDAWgBR8XQKEE9TMipuBzhccLikenEhj\nQjAdBgNVHQ4EFgQU4TqrIXzx401UrtRyt/2LWUIgkFQwHQYDVR0lBBYwFAYIKwYB\nBQUHAwEGCCsGAQUFBwMCMD0GA1UdIAQ2MDQwMgYEVR0gADAqMCgGCCsGAQUFBwIB\nFhxodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tMHgGCCsGAQUFBwEBBGwwajAi\nBggrBgEFBQcwAYYWaHR0cDovL29jc3AuZW1zaWduLmNvbTBEBggrBgEFBQcwAoY4\naHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbS9jZXJ0cy9lbVNpZ25FQ0NSb290\nQ0FHMy5jcnQwMwYDVR0fBCwwKjAooCagJIYiaHR0cDovL2NybC5lbXNpZ24uY29t\nP1Jvb3RDQUczLmNybDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIB\nATAKBggqhkjOPQQDAwNpADBmAjEAt9CIpRGp46QHQYDUBnLKw+vvK0/h9vmilEAg\nTroUl/anH4x2rXHqiCtBTBfgKDegAjEA8WpG1LTZdAIL0FSYqxXeREaeTSxEofM0\n1jJH3jGvCXC79LU9UPJ2o3TU+UIj2gMV\n-----END CERTIFICATE-----\n",
|
||||
"346cdd962e97189b59aee5526d1ae8fa714bb1a64094dfa67bef768ee93f36b5": "-----BEGIN CERTIFICATE-----\nMIICODCCAb+gAwIBAgIUbWvtwSNK/Qro223vAqyz323+VBAwCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBEViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDA1OTM2WhcNMjcwNTAzMDEwNDM2\nWjAvMS0wKwYDVQQDDCRkdi1nM2MtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxlbS5j\nb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAScdBQpNUOOCcag6YEVFccNRSFe\nUIl02iH53vDfoWPJFS005WzqF3CPDclB/kygP2gWF4pg/r5gPYdnI3/6Vdzro4Gn\nMIGkMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsG\nAQUFBwMBMC8GA1UdEQQoMCaCJGR2LWczYy1lbXVkaHJhLndlLWhhdmUtYS1wcm9i\nbGVtLmNvbTAdBgNVHQ4EFgQUBj2yGO/TzDOE/u/6QeUvcW8y1iMwHwYDVR0jBBgw\nFoAUrn4QXDwplsGoRycFWfHgPdTlCvMwCgYIKoZIzj0EAwIDZwAwZAIwBQOOQqG9\nccdzM/7XIlXLtota1esALaLmgEN/ZweosBIvk4xabHcERZInWVIwO45NAjBos8Ue\nNwDe1X3ahdK2lkxqBEoBMtUHJN18TnJKs2nK1gmXpMbmY5oKnAQQgCEZPA4=\n-----END CERTIFICATE-----\n",
|
||||
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIQAKSu5njyM2K0wORgt3LyFDAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTU1\nNDA4WhcNNDAwNDI4MTgzMDAwWjA/MQswCQYDVQQGEwJOTDETMBEGA1UECgwKZU11\nZGhyYSBCVjEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNCMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAE557ZwEqXc8RzXlERHahawr1eZKs3S4DhLLwgOOweTw47gS/J\nhZ4eZ6+wTi+LyX90hbplqpEe6KTkW2LVzAPVa+Z4s8OMtNyTfKJGDiOEU8iSk10p\n1E+R4VYGtjzO2rRoo4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFOIeL+ccGTkOVUtiwSAkVzUkiiUUMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAmQwCAYGZ4EMAQIBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYi5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRFZUTFNDQUczQi5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIwLmsGXi6oFMJ2klkcEMXMug6gvxQWZWkewiDWVzQyl2L6z6DuQfPO\nIbh1grvdULDCAjEAxZvLlxUavG69J6wnSjC8XBbqNIQsJ6CE2QdNOe84Ptxb6VG8\ngt2TlfJbHii+KNvK\n-----END CERTIFICATE-----\n",
|
||||
"4314941cf242a4ea4149aa7e9cdf77a15de2771a79984b809fc04443fea013c8": "-----BEGIN CERTIFICATE-----\nMIICOTCCAb+gAwIBAgIUXcJ4+VeNEICJ9JbBANtIyfP96EowCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBPViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDA1OTM3WhcNMjcwNTAzMDEwNDM3\nWjAvMS0wKwYDVQQDDCRvdi1nM2MtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxlbS5j\nb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATLEvRHdoPc0Z9ovgqlYmoKTHPU\nApwd6lAp0ou1G63SzuESyBzIW6Tl64CsKzlV9ggkZjX+L9/tOx97/1wmSTYvo4Gn\nMIGkMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsG\nAQUFBwMBMC8GA1UdEQQoMCaCJG92LWczYy1lbXVkaHJhLndlLWhhdmUtYS1wcm9i\nbGVtLmNvbTAdBgNVHQ4EFgQUrytBb+px+b58Df+d5vP8GpTW+/wwHwYDVR0jBBgw\nFoAUNOrqDOvJCtr50MXi7Zt4yYeeG2wwCgYIKoZIzj0EAwIDaAAwZQIwNdoOUOKz\nMLV1OFEoMjBeUqvkQAwsajvVWu0A23qyyvYlosyMp8aFv0gxURW6Yj6DAjEAhe0z\nkOQmmqb0eyhjh7weF4fHtwMtLi4jNFA6M/EfPTfkqE91MN9fSin5uhua5xhk\n-----END CERTIFICATE-----\n",
|
||||
"432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIQAIAJkopkEHYBrwX6LxnxCTAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTUz\nOTMxWhcNNDAwNDI4MTgzMDAwWjBAMQswCQYDVQQGEwJVUzEUMBIGA1UECgwLZU11\nZGhyYSBJbmMxGzAZBgNVBAMMEkVNIEVWIFRMUyBDQSAtIEczQzB2MBAGByqGSM49\nAgEGBSuBBAAiA2IABC1W6Cmm+4LGSkaX2WCvlR6QZ66R+SbTQwbaSKiq63hptqZ+\nbSdCbgRCqgOzbhmI3/Y2UU7hzwf1uks/8qBmRa59MdWGatMao9JG6wsxd+6Zy3Vm\nxRbOdICKZo+R8MR/yqOCAZUwggGRMB8GA1UdIwQYMBaAFOE6qyF88eNNVK7Ucrf9\ni1lCIJBUMB0GA1UdDgQWBBSmJV6AqU2385Z8G8eR35K87kvrXzAdBgNVHSUEFjAU\nBggrBgEFBQcDAQYIKwYBBQUHAwIwXAYDVR0gBFUwUzA5BgsrBgEEAYOOIQEAATAq\nMCgGCCsGAQUFBwIBFhxodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tMA0GCysG\nAQQBg44hAQJ4MAcGBWeBDAEBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRVZUTFNDQUczQy5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIwBy1wTnzH4lXDTy2PD91ysLjvnlVN3JbJmc1BIMEU8TOLkYVHibMc\n9HeyonikFSfmAjEA6uE3QRxlrZTz/1/lXPTEMUhoauAtycagtlNFp79kjhvNa2/f\nAal1GNxsOutLnr0Y\n-----END CERTIFICATE-----\n",
|
||||
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe": "-----BEGIN CERTIFICATE-----\nMIIDzjCCAragAwIBAgIRAKAk/fS6Zlx4XOSgIEg4WLcwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW\nMRswGQYDVQQDDBJFTSBEViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAATnntnASpdzxHNeUREdqFrCvV5kqzdLgOEsvCA47B5PDjuBL8mFnh5nr7BO\nL4vJf3SFumWqkR7opORbYtXMA9Vr5nizw4y03JN8okYOI4RTyJKTXSnUT5HhVga2\nPM7atGijggE1MIIBMTAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQU4h4v5xwZOQ5VS2LBICRXNSSKJRQwDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDEGA1UdIAQqMCgwCAYGZ4EMAQIBMA0GCysGAQQBg44hAQABMA0GCysGAQQBg44h\nAQJkMEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FB\nQUNlcnRpZmljYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEF\nBQcwAYYYaHR0cDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IB\nAQBzQ1IoqpdotM6gFrR4Q3YrKifOLeaj5XXunldpVE/vZn2TxC2y0s8oYiLR5ICE\nwqVXSB7N6BM1Mp+Nif7Jbhb2IswAfSR4Iik8+B0iRmi7lYoNlycCgHjmsjOmZaxM\nQkPmjXAJbG5f4V41OsvJZmS7lI3r0IuvEDVX3GAZ13on6IImSs9ybNpHQ7NCqkMQ\n7wH4uAO1dtpFycgJSdHaCOhHjrox2BTYGLdpUrYwY3EOAjlZ8UGgsxs7mG+xquMl\nbN1LFURwfnkVMP3T/DBIZ/ys2+lNFyxK3FlArrV4/000uTnVB1lO8oGORYdXoT5V\nGUyRCEIDhF5TBUuKF4E3kf1h\n-----END CERTIFICATE-----\n",
|
||||
"4b2460a466f95fd40113bcdcca2ceb29b3b093921de45474da7df48874c630b1": "-----BEGIN CERTIFICATE-----\nMIIDWzCCAuGgAwIBAgIPVEt/kiwxriIKSpIpFvbmMAoGCCqGSM49BAMDMFUxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMR8wHQYDVQQDDBZlbVNpZ24gUm9vdCBDUyBDQSAtIEczMB4XDTI1MDQzMDE2Mzg0\nMFoXDTQwMDQyODE4MzAwMFowPzELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRo\ncmEgSW5jMRowGAYDVQQDDBFFTSBPViBDUyBDQSAtIEczQzB2MBAGByqGSM49AgEG\nBSuBBAAiA2IABLnw4GNHAqacpqrdRDz9YsQbm6SyCSRpfXl052kaEm+5avs+NML9\nO8X0gwYfJl3CpYQ2NHMjUQQ2so8E84YjYwZ5gOJQPgJTLWVGI1pxZaJZoe9Jb3Qb\nX97neHII5Lj676OCAYswggGHMB8GA1UdIwQYMBaAFGh0A/iFyo1UUFcdFuDI3kUN\nq80tMB0GA1UdDgQWBBTub3MpjW+/p7Mbw5U09hVWRHhmtzATBgNVHSUEDDAKBggr\nBgEFBQcDAzBeBgNVHSAEVzBVMDkGCysGAQQBg44hAQABMCowKAYIKwYBBQUHAgEW\nHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDgYMKwYBBAGDjiEBAoFIMAgG\nBmeBDAEEATB0BggrBgEFBQcBAQRoMGYwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3Nw\nLWMuZW1zaWduLmNvbTA+BggrBgEFBQcwAoYyaHR0cDovL3JlcG9zaXRvcnkuZW1z\naWduLmNvbS9jZXJ0cy9FTU9WQ1NDQUczQy5wN2MwNgYDVR0fBC8wLTAroCmgJ4Yl\naHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290Y3NjYWczLmNybDAOBgNVHQ8BAf8E\nBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQDAwNoADBlAjEA8yPg\n8sntOhLwV2QXJ5LEaKSpo/fnE3uOgTadK73fDhyjiqr0Io9GVuQTYqBPbQ2TAjBg\neuApmBS486+iei03lRrp139dPw6k28jjGNvWsAU7A1N86J1zPJVDEyVH+nMAWC0=\n-----END CERTIFICATE-----\n",
|
||||
"5510ecf5764dee8dfe9468ae2d7323da21fcd5530889906540a2f8725c6f74db": "-----BEGIN CERTIFICATE-----\nMIICSTCCAdCgAwIBAgIUO7oRaIOhMWD0jFgvxusAGNi5CI0wCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjIxMzE0NDhaFw0yNzA1MDUxMzE5NDha\nMDgxNjA0BgNVBAMMLWR2LWczYi1hYWEtbm9jdC1lbXVkaHJhLndlLWhhdmUtYS1w\ncm9ibGVtLmNvbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJ0yLMf28vw2E6pO\nc74doinU47pwTYD6xZ60nkrGkqI05DxVKXblos+V5S0+bgDjT2qw1F39EW9EUj8b\nNQmmqv6jgbAwga0wDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0l\nBAwwCgYIKwYBBQUHAwEwOAYDVR0RBDEwL4ItZHYtZzNiLWFhYS1ub2N0LWVtdWRo\ncmEud2UtaGF2ZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBTruPPaZ1PiEFfpqdLo\n8Ra5Ah5a2jAfBgNVHSMEGDAWgBTiHi/nHBk5DlVLYsEgJFc1JIolFDAKBggqhkjO\nPQQDAgNnADBkAjBUTUTymJylZiZRV/RqMayZdUPJKRDIcn9+sUPpG66AjVgx8M+h\nDGzxxgeroOSxzSwCMBM/r1y7g371RI10nDU16T0WEAO/Udqyyuc8mJ2LCQ+Gxv3c\njU2NXGQLsvosv6T0ww==\n-----END CERTIFICATE-----\n",
|
||||
"579480aea166290bd3c643589e795fc0d0e54e86505d180f1dff99efd3199512": "-----BEGIN CERTIFICATE-----\nMIIDzTCCArWgAwIBAgIRANj4aF5PK9nYJAKoWhwetzEwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW\nMRswGQYDVQQDDBJFTSBFViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAAQl26haZNOX2gsQUunqVG5Ie+/qnJ2daKDF6tz+73Wmm7XW/AH0K/vKbrQL\nzWTAdn42/9Bdl6gTxaoMbJUw55z18IZkaPE4BkIKpchRhAseJSVA55DktlTDhS7W\nSvHoZX2jggE0MIIBMDAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQUPtwg2YlGmId7hz/YgUeyXBXYRwYwDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDAGA1UdIAQpMCcwBwYFZ4EMAQEwDQYLKwYBBAGDjiEBAAEwDQYLKwYBBAGDjiEB\nAngwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20vQUFB\nQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUF\nBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZIhvcNAQELBQADggEB\nAJNFuAFW9Gnw/8+kCO4KIlZdnM1Sh2u+8cK0EYAHhXpb5FlGvYkcARigo5oS7VoM\n7FOYlTueV6yICZKNAYawZPJPhNCGK+rp3cQBEz6d0n8OZEYZov9lb3ll8ORgSDuf\nFVIW6Gagg/UnOamLBTOR8eD5V73SCme/B2cHUDw+m5E8cc+0Ujzz3Cn3ex5JrhXk\n2T3s8PoVzEmhTgGboFfcWO1EYhPPrYwdxPYRQIXAXUXhLEOVNL9ASbZJLFI8E1IM\nm7NwxVN2s38jWpaCUaaF0tg7hfiy7oe13rzJfHsdqldJVTTUTPWVx8TC4ju0VLet\nbAlwhwFT6AP8uIVMGUHNw24=\n-----END CERTIFICATE-----\n",
|
||||
"59cf0386b56bc2414a0c06241ea9e25e01a9c93bac7a596648747f9d0ded4906": "-----BEGIN CERTIFICATE-----\nMIICQTCCAcegAwIBAgIULuWnWW6kFV6QZxp2ls8r18NCjXkwCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBPViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDIyMjQyWhcNMjcwNTAzMDIyNzQy\nWjAzMTEwLwYDVQQDDChvdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEb7+5g2YHNYwH2eQERrZc\nTmVuf/RmvzsGGMSwXmZke6vn35Ut5HME7UOOU3Muy+BSfjfpo6fjL5KOs3Ndz67d\nOKOBqzCBqDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATAzBgNVHREELDAqgihvdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2\nZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBQ7oBzq3jpTuvntbIkzMV12Ps3O0TAf\nBgNVHSMEGDAWgBQ06uoM68kK2vnQxeLtm3jJh54bbDAKBggqhkjOPQQDAgNoADBl\nAjEA/Y7AZ4M2MYbN5Od9tREaUfFjZKoOjczfIKPjKNMjtGxTdL9WUXvAT4QDgktn\n/IJdAjAbORTkAXD5hggyG6K7p0MBz3lFZJb2l/LndZlrttJWBLP8QGrc44hrZZtK\nIq0d1dE=\n-----END CERTIFICATE-----\n",
|
||||
"5f1ded4dd590af74631290cf073b122a2aac6a1fa479778a0bd43579bd832699": "-----BEGIN CERTIFICATE-----\nMIIDzjCCAragAwIBAgIRAOlCI73pL5FOWrmsq/2sNxAwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW\nMRswGQYDVQQDDBJFTSBPViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAARziXqg6zz9yosSQC/68KvE4SErr43ud4rJ7i1tuD+sKAxeBWOK56Ks1xwy\nKo42HN659pxDArndjXPRzGyqsswK7k4hbOl53VGKSvS2JuVoTN1lgDftDIVOTMEv\nTPhkbQ2jggE1MIIBMTAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQUm9hlUa/n5yw3rQjuaRnZvzeErgowDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDEGA1UdIAQqMCgwCAYGZ4EMAQICMA0GCysGAQQBg44hAQABMA0GCysGAQQBg44h\nAQJuMEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FB\nQUNlcnRpZmljYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEF\nBQcwAYYYaHR0cDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IB\nAQALRoOSvkzRa+tN5Rje9W5GiXd/qsicd9N6Gnh0SWC7VyWH+HycIqAnFCMYZznj\nCeGusomFYHE42SHWr85JDIbgzy6y3dO4i9n9w0Bx7yrXWBbDzFS8VgA3+EY4Cg7y\nmtoC3RPOJVtaXXPZ+68X804g1Mj+3vCSHj2lNPVgco0OzSIOJ/KMg1R9MLBkiKzb\nbERAGZHejY0HBhYd76IINZN8z6bBNah2UdFYxsOisLynPQUkol6gRLum024/FzVl\nCCDfnUusrkr+mAfPAvD/Vb8bhpA7jYtepuU+2mIdShj5lLlvAwZphn+ypWycuo2x\n0k9mldz2dqI9nhCiY+TTIOOR\n-----END CERTIFICATE-----\n",
|
||||
"62b7f9868263f72f6911fc78ceaf72c1979158b7368bf648683bb2aa39b02e72": "-----BEGIN CERTIFICATE-----\nMIIDzzCCAregAwIBAgIRAJHzR/Glt9OZYrleDuTimdEwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIElu\nYzEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0CAQYFK4EE\nACIDYgAEOnp4YsjETTJN3WkDOmeCwO/u/kBV5ooSP6A3G6O9kZRRTuFypDPjYw8C\nvRiAN9nlFJ+bd9uUooHfmHsv6oiLdL3+j9pMEJnubKGVZv4+8OX2sJpVbMyLpQVh\n3xsqRB0Bo4IBNTCCATEwHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQw\nHQYDVR0OBBYEFK5+EFw8KZbBqEcnBVnx4D3U5QrzMA4GA1UdDwEB/wQEAwIBBjAS\nBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD\nAjAxBgNVHSAEKjAoMAgGBmeBDAECATANBgsrBgEEAYOOIQEAATANBgsrBgEEAYOO\nIQECZDBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9B\nQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDA0BggrBgEFBQcBAQQoMCYwJAYIKwYB\nBQUHMAGGGGh0dHA6Ly9vY3NwLmNvbW9kb2NhLmNvbTANBgkqhkiG9w0BAQsFAAOC\nAQEAr7b6Mx11TIJgVzHt6EH5qGNWeiiqoMrp8E5/k8s+jvupx1n083pZ5XOzUzND\nwEUe0W4C4FIGUHNQ4XDwqOw0yK42xqm2dvCKgsko2UvDlT0haZVtTCeWMQOe5J/J\nziaMfZwsZNfZ3aaI6XPtWDZtwY8AaNfyaeCpbtm5GcGnyNWk4jSNpbtpo6NjfXuP\nCFOm3rTjJ4zytP7Tso6iMm0L01cYFOlTmoPv4515R2r3SPnKDtijoB/D/vgxzojv\nDafgyG9afX8+laO9NbMMz4sqC1vZx3984i/sBNFiugm3Vvdq5OdJRjuGC1QILIs6\ndg/K3i8VQqRpbfaM9kIlMzZ3dQ==\n-----END CERTIFICATE-----\n",
|
||||
"66374ae64962c437bd815a2aae546c76e30c2eafac8335192c8e517c15a246ed": "-----BEGIN CERTIFICATE-----\nMIICNzCCAb6gAwIBAgIUPfthO0PYGAYKs9lQ4gp1IOkOr2MwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIEVWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMDU5MzZaFw0yNzA1MDMwMTA0MzZa\nMC8xLTArBgNVBAMMJGV2LWczYi1lbXVkaHJhLndlLWhhdmUtYS1wcm9ibGVtLmNv\nbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABPZPwlA3io/AX/pMwu0dNl9WMxrr\nC9j/xMLHgG72cWVzoX62REmvp9zyXQ04kHP9/sW6KnCcm5ZZ5i4YoJbDPASjgacw\ngaQwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYB\nBQUHAwEwLwYDVR0RBCgwJoIkZXYtZzNiLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMB0GA1UdDgQWBBSvX4QR4ERW5uNUaMq0yYzXCZf0ZjAfBgNVHSMEGDAW\ngBQ+3CDZiUaYh3uHP9iBR7JcFdhHBjAKBggqhkjOPQQDAgNnADBkAjACy/uJR01M\n/O9IEZyRHX1LSU28bmg542K0IX9jDRTrZXUqkDWfiZIL7xfnrRrBpHoCMFbBIaOa\nWwWtFtDg9ZqQvktsWS950u5qfW1sChgWNya/E3IMKFgtoHm2xrZsmfwLSQ==\n-----END CERTIFICATE-----\n",
|
||||
"7485b51c0ed192878fa1d2f948fb529ae1842cc5086c4b67f7be5b2039de9489": "-----BEGIN CERTIFICATE-----\nMIIDzzCCAregAwIBAgIRAI1aSBGTnvRH13ERzdmEmukwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIElu\nYzEbMBkGA1UEAwwSRU0gT1YgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0CAQYFK4EE\nACIDYgAEE4JRzVKskpjByKrZdzId65fnCb0LTKCspV3IrVHc/J0ViaFZfjpRIOHv\n1jHGPhg1ysrimGmmLhYx6KKBgatWYW3EXZGKvAnzqw5jz3kqpNztc4e+N7ulaVSf\nHAKycO1no4IBNTCCATEwHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQw\nHQYDVR0OBBYEFDTq6gzryQra+dDF4u2beMmHnhtsMA4GA1UdDwEB/wQEAwIBBjAS\nBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD\nAjAxBgNVHSAEKjAoMAgGBmeBDAECAjANBgsrBgEEAYOOIQEAATANBgsrBgEEAYOO\nIQECbjBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9B\nQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDA0BggrBgEFBQcBAQQoMCYwJAYIKwYB\nBQUHMAGGGGh0dHA6Ly9vY3NwLmNvbW9kb2NhLmNvbTANBgkqhkiG9w0BAQsFAAOC\nAQEAHlbhZpjhkPEAQVhPHRtBDaHqiisVD20pQ1TUeSx3l62Qe7NQ0b7vWQy7DN0i\nKOu7BGpP5EnIjvXbz9FhpfPJ4FhKEt3tQ51rqXz2oD8vRaryvExWPobDUz3j7HN6\nOzBsVMIe3PD7AAkpky0x8zMSi/ep5R9OMzybmNptbtppyuB5ytgq+h7lQdRIP+CQ\nTaJX3qTgfMqzntYre5GljmEB5hU2/rSn8zoSU2APkJidlZFpghtgbD4I2VRHZvCw\nFFr/SrFM9pwrgbJNZ1MtyTSVgI5NlNb0aMfLR+/r6jJt3ROeY2yh2QQ2dx+Yqgdb\nuL5ChFWOQU+ErSDnUq7yr+pjBQ==\n-----END CERTIFICATE-----\n",
|
||||
"7dd78d5f4f13459a83dff9abbba62edbaf6f2d102bf257fd712f4d9f2746ed8d": "-----BEGIN CERTIFICATE-----\nMIICKTCCAa+gAwIBAgIQDnYGcvFDRZ/I/gqwvAXjlDAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjQwMjA4MDgx\nOTMyWhcNNDkwMjA3MDgxOTMyWjBWMQswCQYDVQQGEwJJTjElMCMGA1UECgwcZU11\nZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAwwXZW1TaWduIFJvb3Qg\nVExTIENBIC0gRzMwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARfD9w/q1cWxL6D9bMG\nH6CQ4u+gAV6GN9s+PwQx9Bl4hC+0iU8QOk8mWO/RwXQy10DCSxT8vSIJClHKp+Ao\n4qJKdYeV0SwZ7LMQziyLfxIUOY6Grja/sQFwLKYf8EdDXAyjQjBAMB0GA1UdDgQW\nBBThOqshfPHjTVSu1HK3/YtZQiCQVDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/\nBAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjBhCFTkly7Yl7+s4hiw+k6ZJTXT2AiI\nrvmsmfqPr5E7ecwXb6kXA5LTFd6rjwTuV3sCMQC9w6ZSbVq1mveMO6dKFziOzClT\nKCT2RFT6yXtq6nzgRICgBmiXbJAXsmZ8yAwjyWc=\n-----END CERTIFICATE-----\n",
|
||||
"7e02622ab6d745c1edb4b431bb1c551e667305c3dd3eb0c7cc6dfdb523807424": "-----BEGIN CERTIFICATE-----\nMIICQDCCAcagAwIBAgIUU3mgjls/8ZJ8upmukdlSQR1IF/UwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMjIyNDFaFw0yNzA1MDMwMjI3NDFa\nMDMxMTAvBgNVBAMMKGR2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxl\nbS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATK3sfcOxxtM2Vdq9Qbw/GR\nNsHlJcnqINEInXO3fkWhw/FYT80hkqGk0jSN/EKib3YZJsr5GyfkYcpEiA2rwMgm\no4GrMIGoMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoG\nCCsGAQUFBwMBMDMGA1UdEQQsMCqCKGR2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZl\nLWEtcHJvYmxlbS5jb20wHQYDVR0OBBYEFDggfJFz1eeToKmzFBSYkMCvpAu0MB8G\nA1UdIwQYMBaAFOIeL+ccGTkOVUtiwSAkVzUkiiUUMAoGCCqGSM49BAMCA2gAMGUC\nMDqDDOt7K313QIH0rhn/ZKnzFu8+/9W8OlTuFtDfVCTRDfRmqdyfsCfLsEk7xqHq\nuQIxAJqyLERABmU98U46OCuT5bp5h0ytuSEIGM7orTpKci5S1u1B/AOmwv162U7R\nTcVPHA==\n-----END CERTIFICATE-----\n",
|
||||
"84112972b8e59d14560ae7e7119654474aad119990cf3722bfc1e3b91615e1fd": "-----BEGIN CERTIFICATE-----\nMIIDxTCCAq2gAwIBAgIRANal72E7OQ8y75zfNJ93KEEwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIElu\nYzEaMBgGA1UEAwwRRU0gT1YgQ1MgQ0EgLSBHM0MwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAAS58OBjRwKmnKaq3UQ8/WLEG5uksgkkaX15dOdpGhJvuWr7PjTC/TvF9IMG\nHyZdwqWENjRzI1EENrKPBPOGI2MGeYDiUD4CUy1lRiNacWWiWaHvSW90G1/e53hy\nCOS4+u+jggEsMIIBKDAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQU7m9zKY1vv6ezG8OVNPYVVkR4ZrcwDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwEwYDVR0lBAwwCgYIKwYBBQUHAwMwMgYDVR0gBCsw\nKTAIBgZngQwBBAEwDQYLKwYBBAGDjiEBAAEwDgYMKwYBBAGDjiEBAoFIMEMGA1Ud\nHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FBQUNlcnRpZmlj\nYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEFBQcwAYYYaHR0\ncDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQBgGbZ+NDkn\n2Fr5dCVs/51+6cT5iEPP1stglX4TI2UaNh9LmPdltxXw3crV8gK5L8i+d+ScGibF\ny0obtBHNTldXDgLnAHIzY3n+pceyKj7O2ae9CYnyuu5RBifeI3XYKowWE+oXnVGF\nOy0CvCJ/JsdP6aYgoCa9hGnAYicYQQL6MOMQZ5PuhcSM8t4giIzrpgrEkUP93GYE\nemDG5Z8BYwKs80lLeQNBnwg/VHJ3iEF/o84RXYwrlrKqJ+33C094o6LNikMJCqOK\n/WYHZSR8Xyhf8GXFAj5pZuI6Bcpug2V/E7i42j8OBhc25aNYDqK+B0qmb90sjNUB\ng3tJeSIb/599\n-----END CERTIFICATE-----\n",
|
||||
"86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6b": "-----BEGIN CERTIFICATE-----\nMIICTjCCAdOgAwIBAgIKPPYHqWhwDtqLhDAKBggqhkjOPQQDAzBrMQswCQYDVQQG\nEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNo\nbm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0g\nRzMwHhcNMTgwMjE4MTgzMDAwWhcNNDMwMjE4MTgzMDAwWjBrMQswCQYDVQQGEwJJ\nTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9s\nb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0gRzMw\ndjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQjpQy4LRL1KPOxst3iAhKAnjlfSU2fySU0\nWXTsuwYc58Byr+iuL+FBVIcUqEqy6HyC5ltqtdyzdc6LBtCGI79G1Y4PPwT01xyS\nfvalY8L1X44uT6EYGQIrMgqCZH0Wk9GjQjBAMB0GA1UdDgQWBBR8XQKEE9TMipuB\nzhccLikenEhjQjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggq\nhkjOPQQDAwNpADBmAjEAvvNhzwIQHWSVB7gYboiFBS+DCBeQyh+KTOgNG3qxrdWB\nCUfvO6wIBHxcmbHtRwfSAjEAnbpV/KlK6O3t5nYBQnvI+GDZjVGLVTv7jHvrZQnD\n+JbNR6iC8hZVdyR+EhCVBCyj\n-----END CERTIFICATE-----\n",
|
||||
"9200d153c3cac272cc06bf68f9ea163fc24659fc3331e77668236fe00645ae52": "-----BEGIN CERTIFICATE-----\nMIICQjCCAcegAwIBAgIUMH3z62Z6agEULBZqrfhpw9/ZpCAwCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBEViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDIyMjQxWhcNMjcwNTAzMDIyNzQx\nWjAzMTEwLwYDVQQDDChkdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEAMPpHsQqEqIerrj/durO\nEN7gkUVJSVgIPN6+VJ2sEGXDyqXAN1fnNzGuASP/4WEr0RIhht3cYvuUYp2SYofr\nw6OBqzCBqDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATAzBgNVHREELDAqgihkdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2\nZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBT8CNQz4OLVnIwrQYZBF+++9jTCvzAf\nBgNVHSMEGDAWgBSufhBcPCmWwahHJwVZ8eA91OUK8zAKBggqhkjOPQQDAgNpADBm\nAjEAp3ZLdfBvKcZxgR2p8LZaxWzP6uB7zU/EZ9BA49Ds2P5ByYkRodwK5qcwt4J/\nVL/vAjEAnnxrcrDX7SzoJxuho604GBh6eyBLJLCdmcy+qb+LA3u5oxP/OjAzCA4x\nv1kw6Tm4\n-----END CERTIFICATE-----\n",
|
||||
"98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b": "-----BEGIN CERTIFICATE-----\nMIIDZjCCAu2gAwIBAgIPbK2jZ/scEACIEg6iAtVoMAoGCCqGSM49BAMDMFYxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNjAw\nMTJaFw00MDA0MjgxODMwMDBaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVk\naHJhIEJWMRswGQYDVQQDDBJFTSBPViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIB\nBgUrgQQAIgNiAARziXqg6zz9yosSQC/68KvE4SErr43ud4rJ7i1tuD+sKAxeBWOK\n56Ks1xwyKo42HN659pxDArndjXPRzGyqsswK7k4hbOl53VGKSvS2JuVoTN1lgDft\nDIVOTMEvTPhkbQ2jggGWMIIBkjAfBgNVHSMEGDAWgBThOqshfPHjTVSu1HK3/YtZ\nQiCQVDAdBgNVHQ4EFgQUm9hlUa/n5yw3rQjuaRnZvzeErgowHQYDVR0lBBYwFAYI\nKwYBBQUHAwEGCCsGAQUFBwMCMF0GA1UdIARWMFQwOQYLKwYBBAGDjiEBAAEwKjAo\nBggrBgEFBQcCARYcaHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbTANBgsrBgEE\nAYOOIQECbjAIBgZngQwBAgIwdQYIKwYBBQUHAQEEaTBnMCQGCCsGAQUFBzABhhho\ndHRwOi8vb2NzcC1iLmVtc2lnbi5jb20wPwYIKwYBBQUHMAKGM2h0dHA6Ly9yZXBv\nc2l0b3J5LmVtc2lnbi5jb20vY2VydHMvRU1PVlRMU0NBRzNCLnA3YzA3BgNVHR8E\nMDAuMCygKqAohiZodHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3R0bHNjYWczLmNy\nbDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQD\nAwNnADBkAjBAk+5BizdJhrED3wb7F2Gm7bWI60dlNd075nghT2TrR4NWKZtJZcbc\n28xxIY/NONICMF6mPdsjosaqmvEMWqKcBh4vsIvXSKnQj5o8zrFB4oKS+accjYTe\n8Dz3bQjauvy9uw==\n-----END CERTIFICATE-----\n",
|
||||
"9dcb21b1841d75313b3c888f4bb9cc01f8f51379a76fbaa241ca492b668839e7": "-----BEGIN CERTIFICATE-----\nMIICOTCCAb6gAwIBAgIUY1R/PHrtXShV0uhLJ5EyjsHvMi0wCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMDU5MzZaFw0yNzA1MDMwMTA0MzZa\nMC8xLTArBgNVBAMMJGR2LWczYi1lbXVkaHJhLndlLWhhdmUtYS1wcm9ibGVtLmNv\nbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABFpE24ACBFHQLzpg+7D767thJ3+Q\nNIoJJugUMT/OftEz6G6uDdxlS+9VfXYBDglHjJirjEPFNglOGJoQNQuiz7Gjgacw\ngaQwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYB\nBQUHAwEwLwYDVR0RBCgwJoIkZHYtZzNiLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMB0GA1UdDgQWBBT4r5v4etYNuG21BqBqs273OkcHiDAfBgNVHSMEGDAW\ngBTiHi/nHBk5DlVLYsEgJFc1JIolFDAKBggqhkjOPQQDAgNpADBmAjEAlxNF+/8l\nAPpgibawACy15yn5bcvTksz2Zw0R9z9nUK9e3MSSk+hXagg7LVb7V4NtAjEA4qyR\nlaFPkaokrnTjqQieq8CgDQ6tIz3FjBvj3D7vkhKXHaml6zD2zT5ih2Y+lD2h\n-----END CERTIFICATE-----\n",
|
||||
"9e39de2881273acc225a149c72ba3e0f51424963669ab633ee1dc7df5db067b4": "-----BEGIN CERTIFICATE-----\nMIIDWzCCAuCgAwIBAgIPeBiTP4+FtGGaKzX7qD0KMAoGCCqGSM49BAMDMFUxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMR8wHQYDVQQDDBZlbVNpZ24gUm9vdCBDUyBDQSAtIEczMB4XDTI1MDQzMDE2MDgy\nN1oXDTQwMDQyODE4MzAwMFowPjELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRo\ncmEgQlYxGjAYBgNVBAMMEUVNIE9WIENTIENBIC0gRzNCMHYwEAYHKoZIzj0CAQYF\nK4EEACIDYgAEqrsPsGZqz88B+tUr/P1MK/LLPDSX3nOVDlgZth/mlQmHQcjEkUOb\nNKBDHK/+W/m0wcjorgAB+fTcitgsWfNjnpbNbD1NsM7+wmw9YlF7N3YTcnQ/jord\nd7EAZPKr5eBQo4IBizCCAYcwHwYDVR0jBBgwFoAUaHQD+IXKjVRQVx0W4MjeRQ2r\nzS0wHQYDVR0OBBYEFHn66OzP2w11jaRY1p+27dx8Sd7mMBMGA1UdJQQMMAoGCCsG\nAQUFBwMDMF4GA1UdIARXMFUwOQYLKwYBBAGDjiEBAAEwKjAoBggrBgEFBQcCARYc\naHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbTAOBgwrBgEEAYOOIQECgUgwCAYG\nZ4EMAQQBMHQGCCsGAQUFBwEBBGgwZjAkBggrBgEFBQcwAYYYaHR0cDovL29jc3At\nYi5lbXNpZ24uY29tMD4GCCsGAQUFBzAChjJodHRwOi8vcmVwb3NpdG9yeS5lbXNp\nZ24uY29tL2NlcnRzL0VNT1ZDU0NBRzNCLnA3YzA2BgNVHR8ELzAtMCugKaAnhiVo\ndHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3Rjc2NhZzMuY3JsMA4GA1UdDwEB/wQE\nAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMAoGCCqGSM49BAMDA2kAMGYCMQC/zr03\nYEELsNKqzUtH2dVFi4BaKFdyrr/6G1dIWp8KAVgsw2/u4lyyboUDaC9jDvICMQC9\nHTxku84tNX9dmHOtH7aC2Nu/JM0jTf6KjZAvovCJzzv1IFWSZpVLZibynMLXOrM=\n-----END CERTIFICATE-----\n",
|
||||
"a496e23ac0cab8155fc9b117b97eae368efcbb9041aec8804d5a308d2d01c2d8": "-----BEGIN CERTIFICATE-----\nMIIDwzCCAqugAwIBAgIQQeMrc6wYXWzg10gLrPhOaTANBgkqhkiG9w0BAQsFADB7\nMQswCQYDVQQGEwJHQjEbMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYD\nVQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UE\nAwwYQUFBIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTI1MDUxNDAwMDAwMFoXDTI4\nMTIzMTIzNTk1OVowPjELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYx\nGjAYBgNVBAMMEUVNIE9WIENTIENBIC0gRzNCMHYwEAYHKoZIzj0CAQYFK4EEACID\nYgAEqrsPsGZqz88B+tUr/P1MK/LLPDSX3nOVDlgZth/mlQmHQcjEkUObNKBDHK/+\nW/m0wcjorgAB+fTcitgsWfNjnpbNbD1NsM7+wmw9YlF7N3YTcnQ/jordd7EAZPKr\n5eBQo4IBLDCCASgwHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQwHQYD\nVR0OBBYEFHn66OzP2w11jaRY1p+27dx8Sd7mMA4GA1UdDwEB/wQEAwIBBjASBgNV\nHRMBAf8ECDAGAQH/AgEAMBMGA1UdJQQMMAoGCCsGAQUFBwMDMDIGA1UdIAQrMCkw\nCAYGZ4EMAQQBMA0GCysGAQQBg44hAQABMA4GDCsGAQQBg44hAQKBSDBDBgNVHR8E\nPDA6MDigNqA0hjJodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9BQUFDZXJ0aWZpY2F0\nZVNlcnZpY2VzLmNybDA0BggrBgEFBQcBAQQoMCYwJAYIKwYBBQUHMAGGGGh0dHA6\nLy9vY3NwLmNvbW9kb2NhLmNvbTANBgkqhkiG9w0BAQsFAAOCAQEAIh0OzWsdBYhY\n7+yxPGyosRL+uUSN6/Mrd30fBuougs5FVWQ3cRgLhImnUUgXADY9buugWhMmfT3c\nyU5T1MFXZXClCILm5mfg+oqm/ikUWJl3Ayf2GvYf2O++6vzqvkn418nI5Lpdt0O7\nLi524YS/3CZHDA+zZQ7rrHpZXgQ9Hmp8AUQ4FneaGjNanFoYxiZxwCyW5z4u7AoK\n0bmOVqKtcDFoVNz5++G9TYDJdhWOXmKKgzqRo1MK0PjazNT7MGHqknP93SqMVJ1g\nmX/qNWxYteAjlvFanQxBNX/+TRxDjuFsEHO/7BvqSpIIck0UEEfizhN8zNpWLbcl\nEqVgl0g39A==\n-----END CERTIFICATE-----\n",
|
||||
"bc88231a840e54cced148da48806169e8d0b1c0a96267a427aeccda9d10e6145": "-----BEGIN CERTIFICATE-----\nMIICQDCCAcagAwIBAgIUUYu3O8bpN1GPvCIHqWpT/FYUNDowCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIEVWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMjIyNDFaFw0yNzA1MDMwMjI3NDFa\nMDMxMTAvBgNVBAMMKGV2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxl\nbS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASjDLpnVdmvXM6R3UB2o42a\n1GnrYfxRXo7UII36mBl9mIS7p9MkAryhPdxitFHkgTrrbypVtzpydNtJIGGGXUwE\no4GrMIGoMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoG\nCCsGAQUFBwMBMDMGA1UdEQQsMCqCKGV2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZl\nLWEtcHJvYmxlbS5jb20wHQYDVR0OBBYEFEVBY1ecTN9bLMDl4ifdnN2WZL+WMB8G\nA1UdIwQYMBaAFD7cINmJRpiHe4c/2IFHslwV2EcGMAoGCCqGSM49BAMCA2gAMGUC\nMQCyvr9gktgmtsuTuFleFV+YExNNKLf3GZieuUprQcERrzh798oYKavTqZeYf9Kz\nRhECMCVs1B7KPh7/lgHRly14Z8RJA8ApR7e2cRREW5uPKFOSDlZ/RIezxsfEzsQ4\nlAODeA==\n-----END CERTIFICATE-----\n",
|
||||
"bcd6ab59ea95fd520bfe7709679b1f682abbd79c77367e84e25415b91de37753": "-----BEGIN CERTIFICATE-----\nMIICQDCCAcegAwIBAgIUTMUrGzSU+0BySFdrN3ALZq9Spv8wCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBFViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDIyMjQyWhcNMjcwNTAzMDIyNzQy\nWjAzMTEwLwYDVQQDDChldi1nM2MtYWFhLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEglClDbotLFFeY9SpryT4\nk958tvriY2DDnaPsmjQaecRhB6+L/8hLw6FMt+HT8SLvjIZnFY+AH92cBjedcNST\nAKOBqzCBqDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATAzBgNVHREELDAqgihldi1nM2MtYWFhLWVtdWRocmEud2UtaGF2\nZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBSVtGbn6xUT1iosUeaGA15Nq640aTAf\nBgNVHSMEGDAWgBSmJV6AqU2385Z8G8eR35K87kvrXzAKBggqhkjOPQQDAgNnADBk\nAjAg7b5bIK9X50QEX0U6G8fQh12BnFQ079Iluzd0Y2TJWCjtVXZi3nOciM8RzNKM\ntZECMCBf0JOyMt8RNS93Wj8kYbWkCtoKoUrqx7G3JTfGReKci+DdTvkF8/3F6doi\n9KB22Q==\n-----END CERTIFICATE-----\n",
|
||||
"cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIPKWkmwiejoVJjkrGUgqUEMAoGCCqGSM49BAMDMFYxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNTM3\nNDFaFw00MDA0MjgxODMwMDBaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVk\naHJhIEluYzEbMBkGA1UEAwwSRU0gT1YgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAEE4JRzVKskpjByKrZdzId65fnCb0LTKCspV3IrVHc/J0ViaFZ\nfjpRIOHv1jHGPhg1ysrimGmmLhYx6KKBgatWYW3EXZGKvAnzqw5jz3kqpNztc4e+\nN7ulaVSfHAKycO1no4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFDTq6gzryQra+dDF4u2beMmHnhtsMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAm4wCAYGZ4EMAQICMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNT1ZUTFNDQUczQy5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIxAMSZt2xsPeumRdeSMSQ3CbzIZeQ/q7GDCWfeE1Pq+mhgA6gRM44c\nNVF3xJ+wsxJk+wIwFJbaDMokD8Ei6eNOiA8yUMYvfzQyngmrhZQMdPCK1SewrR79\n4rSb9vGqxZzelQth\n-----END CERTIFICATE-----\n",
|
||||
"ccd80467f9cac38f115dcb6e4f57e4d74f5efa519814ea934aeb3be53b83a191": "-----BEGIN CERTIFICATE-----\nMIICOTCCAb+gAwIBAgIUOcw5N4XGLiB/3GvB05bJuGoZPR0wCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBFViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDA1OTM3WhcNMjcwNTAzMDEwNDM3\nWjAvMS0wKwYDVQQDDCRldi1nM2MtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxlbS5j\nb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQo7U02MAUO8EEMomXwIl+kSQzm\nb+nvXKIZ/hC4rZ1GpXchv4SzTrV1SB/Qg+pD6OBEXbBBnPmQNkePNQTlujqRo4Gn\nMIGkMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsG\nAQUFBwMBMC8GA1UdEQQoMCaCJGV2LWczYy1lbXVkaHJhLndlLWhhdmUtYS1wcm9i\nbGVtLmNvbTAdBgNVHQ4EFgQUqxe+7qS+MxNyioJZhQsL5MEEw6swHwYDVR0jBBgw\nFoAUpiVegKlNt/OWfBvHkd+SvO5L618wCgYIKoZIzj0EAwIDaAAwZQIwC/CxSIov\nwcar3sV98B8cL+QuR2kkJdLdXsFKzg/HDSJ1MOjqTGm924LB6r9nl0f7AjEAzcwr\nWQOYY9eycqIz+LG2daYS6LOctj+CjoAk/CLugWPxGxp4Aerfau2P1z1h5jAA\n-----END CERTIFICATE-----\n",
|
||||
"d7a7a0fb5d7e2731d771e9484ebcdef71d5f0c3e0a2948782bc83ee0ea699ef4": "-----BEGIN CERTIFICATE-----\nMIIEMjCCAxqgAwIBAgIBATANBgkqhkiG9w0BAQUFADB7MQswCQYDVQQGEwJHQjEb\nMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRow\nGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UEAwwYQUFBIENlcnRpZmlj\nYXRlIFNlcnZpY2VzMB4XDTA0MDEwMTAwMDAwMFoXDTI4MTIzMTIzNTk1OVowezEL\nMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UE\nBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNVBAMM\nGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczCCASIwDQYJKoZIhvcNAQEBBQADggEP\nADCCAQoCggEBAL5AnfRu4ep2hxxNRUSOvkbIgwadwSr+GB+O5AL686tdUIoWMQua\nBtDFcCLNSS1UY8y2bmhGC1Pqy0wkwLxyTurxFa70VJoSCsN6sjNg4tqJVfMiWPPe\n3M/vg4aijJRPn2jymJBGhCfHdr/jzDUsi14HZGWCwEiwqJH5YZ92IFCokcdmtet4\nYgNW8IoaE+oxox6gmf049vYnMlhvB/VruPsUK6+3qszWY19zjNoFmag4qMsXeDZR\nrOme9Hg6jc8P2ULimAyrL58OAd7vn5lJ8S3frHRNG5i1R8XlKdH5kBjHYpy+g8cm\nez6KJcfA3Z3mNWgQIJ2P2N7Sw4ScDV7oL8kCAwEAAaOBwDCBvTAdBgNVHQ4EFgQU\noBEKIz6W8Qfs4q8p74Klf9AwpLQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF\nMAMBAf8wewYDVR0fBHQwcjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20v\nQUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNqA0oDKGMGh0dHA6Ly9jcmwuY29t\nb2RvLm5ldC9BQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDANBgkqhkiG9w0BAQUF\nAAOCAQEACFb8AvCb6P+k+tZ7xkSAzk/ExfYAWMymtrwUSWgEdujm7l3sAg9g1o1Q\nGE8mTgHj5rCl7r+8dFRBv/38ErjHT1r0iWAFf2C3BUrz9vHCv8S5dIa2LX1rzNLz\nRt0vxuBqw8M0Ayx9lt1awg6nCpnBBYurDC/zXDrPbDdVCYfeU0BsWO/8tqtlbgT2\nG9w84FoVxp7Z8VlIMCFlA2zs6SFz7JsDoeA3raAVGI/6ugLOpyypEBMs1OUIJqsi\nl2D4kF501KKaU73yqWjgom7C12yxow+ev+to51byrvLjKzg6CYG1a4XXvi3tPxq3\nsmPi9WIsgtRqAEFQ8TmDn5XpNpaYbg==\n-----END CERTIFICATE-----\n",
|
||||
"e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu2gAwIBAgIQAMfEHv6S2CXwXq6EGkqCwTAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTYw\nMzMxWhcNNDAwNDI4MTgzMDAwWjA/MQswCQYDVQQGEwJOTDETMBEGA1UECgwKZU11\nZGhyYSBCVjEbMBkGA1UEAwwSRU0gRVYgVExTIENBIC0gRzNCMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAEJduoWmTTl9oLEFLp6lRuSHvv6pydnWigxerc/u91ppu11vwB\n9Cv7ym60C81kwHZ+Nv/QXZeoE8WqDGyVMOec9fCGZGjxOAZCCqXIUYQLHiUlQOeQ\n5LZUw4Uu1krx6GV9o4IBlTCCAZEwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFD7cINmJRpiHe4c/2IFHslwV2EcGMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBcBgNVHSAEVTBTMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAngwBwYFZ4EMAQEwdQYIKwYBBQUHAQEEaTBnMCQGCCsGAQUFBzABhhho\ndHRwOi8vb2NzcC1iLmVtc2lnbi5jb20wPwYIKwYBBQUHMAKGM2h0dHA6Ly9yZXBv\nc2l0b3J5LmVtc2lnbi5jb20vY2VydHMvRU1FVlRMU0NBRzNCLnA3YzA3BgNVHR8E\nMDAuMCygKqAohiZodHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3R0bHNjYWczLmNy\nbDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQD\nAwNpADBmAjEA9Jq999oaOUKhFwopw33iCmt0OHIn3gjT43GfuOHR/YckXdwoNdDA\nlIEhz0NgRO3/AjEAvY/a3OMvktIZLYjn+WLKmp0QhrIMV+e6EpS7j4GVeOKouNp6\nMN/P+ByGeiRMghzB\n-----END CERTIFICATE-----\n",
|
||||
"f2edaaff3d9b701fb4d14c9f285f8c0bad90fc3bcc1fa07d47f4718edaba34a5": "-----BEGIN CERTIFICATE-----\nMIICKDCCAa2gAwIBAgIQAY/OySfBFvWcmX7cDM0uKzAKBggqhkjOPQQDAzBVMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEfMB0GA1UEAwwWZW1TaWduIFJvb3QgQ1MgQ0EgLSBHMzAeFw0yNDAyMDgwODMz\nMTRaFw00OTAyMDcwODMzMTRaMFUxCzAJBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVk\naHJhIFRlY2hub2xvZ2llcyBMaW1pdGVkMR8wHQYDVQQDDBZlbVNpZ24gUm9vdCBD\nUyBDQSAtIEczMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAESl9kM5D0VA/Og76dz5Xz\nw1hnGQ4fSR9993/qBkkvI48pXQVKOdyHCgAZThgWejQ8OpFRiTISjpabn4tfRFOY\nuzhApR/XxGbkg9X+2cEtbyghF9CdoF91W22V/R2EVe4xo0IwQDAdBgNVHQ4EFgQU\naHQD+IXKjVRQVx0W4MjeRQ2rzS0wDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF\nMAMBAf8wCgYIKoZIzj0EAwMDaQAwZgIxAK9OlACrFod3UgWY1ai+DF9sGFhahU59\nrv4jJyV01QaxGn0HErZ8gjHkngqe+tcFogIxAPTdW0hBmrGjhxBWdv9mZNdVFiJ4\nlrZ3ahvimsLhTQpnDBgiGKv6Ov1Fx/CE6yjIAg==\n-----END CERTIFICATE-----\n",
|
||||
"f3c3c15bc7edf2b3ebdc922af112f2a0c414eb509901099ef0488c0942e77efe": "-----BEGIN CERTIFICATE-----\nMIICODCCAb6gAwIBAgIUPXjC97k7buQHZ7p5ehDU8QNyrR4wCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIE9WIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMDU5MzdaFw0yNzA1MDMwMTA0Mzda\nMC8xLTArBgNVBAMMJG92LWczYi1lbXVkaHJhLndlLWhhdmUtYS1wcm9ibGVtLmNv\nbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABA1haD+95Albk0UN6YtDCPpqevTv\nYUmEhsIVpb/hnhsbBlH0yLthSE2JLH14ywiQJXttoc/pDWC9eoICW6CJXL2jgacw\ngaQwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYB\nBQUHAwEwLwYDVR0RBCgwJoIkb3YtZzNiLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMB0GA1UdDgQWBBQUhzc4s202Hn62G0srmgguYdY2MTAfBgNVHSMEGDAW\ngBSb2GVRr+fnLDetCO5pGdm/N4SuCjAKBggqhkjOPQQDAgNoADBlAjEAoJ6d4zzA\nxE349yGHHrwKZpVCC/HBFutcOpOfzQoe8ikbmaaBTdtKai5TEllwt3dBAjBeHGiX\nVheDhJfewbei99cw1MfYB5tPwdfuY5pe1MJz0Tg9zJ4JgxBvF6LTT0OOXZ4=\n-----END CERTIFICATE-----\n",
|
||||
"fd9d5c2dbbd4995ab69b8d50c439d9527b00529199c2fd01b2174eb58f07021f": "-----BEGIN CERTIFICATE-----\nMIIDzTCCArWgAwIBAgIQBfB4dS3s56G7hZXTAxZxcTANBgkqhkiG9w0BAQsFADB7\nMQswCQYDVQQGEwJHQjEbMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYD\nVQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UE\nAwwYQUFBIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTI1MDUxNDAwMDAwMFoXDTI4\nMTIzMTIzNTk1OVowQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5j\nMRswGQYDVQQDDBJFTSBFViBUTFMgQ0EgLSBHM0MwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAAQtVugppvuCxkpGl9lgr5UekGeukfkm00MG2kioqut4abamfm0nQm4EQqoD\ns24ZiN/2NlFO4c8H9bpLP/KgZkWufTHVhmrTGqPSRusLMXfumct1ZsUWznSAimaP\nkfDEf8qjggE0MIIBMDAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQUpiVegKlNt/OWfBvHkd+SvO5L618wDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDAGA1UdIAQpMCcwBwYFZ4EMAQEwDQYLKwYBBAGDjiEBAAEwDQYLKwYBBAGDjiEB\nAngwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20vQUFB\nQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUF\nBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZIhvcNAQELBQADggEB\nAIFfHiaMSjnrdZygRYV4ekRBmG7ybcFk4hGQWMMyMFi0cAEk2+dGfz5bl8IaIbLq\nz+uVJzhOgVEX7WqIfgbWfFsfPYHYjPHBmy7SlZ3BwA86IQ6qF/etpX2/qL9A5yRN\nN3nVowUpaqV6H3aCpkAh3wR2ZNkvAbLDvPz3J0l+sEWjWv+ZISOV4R+hXDTdPXV0\n1lKxnbwqEsdo/sE2QkCDKwFrZh/0uKjWspydJj+jSaDLTrGNx50Dgmn7VEcD1ZI5\nFXjknVUiO25YlmS1dtVZSBbyUKSjlrP/yDwPcZunoAsmCAAtCKsFLviLaymzvsKI\neSSDGxpSySp2VCxompGDk8g=\n-----END CERTIFICATE-----\n"
|
||||
},
|
||||
"chains": {
|
||||
"dv-g3b-aaa-emudhra.we-have-a-problem.com": [
|
||||
"7e02622ab6d745c1edb4b431bb1c551e667305c3dd3eb0c7cc6dfdb523807424",
|
||||
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe"
|
||||
],
|
||||
"dv-g3b-aaa-noct-emudhra.we-have-a-problem.com": [
|
||||
"5510ecf5764dee8dfe9468ae2d7323da21fcd5530889906540a2f8725c6f74db",
|
||||
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe"
|
||||
],
|
||||
"dv-g3b-emudhra.we-have-a-problem.com": [
|
||||
"9dcb21b1841d75313b3c888f4bb9cc01f8f51379a76fbaa241ca492b668839e7",
|
||||
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"dv-g3b-noct-emudhra.we-have-a-problem.com": [
|
||||
"0cbfe22b824385823fd3bf4be4d131e8901342933a7feef1cf8b3110b8e44a70",
|
||||
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"dv-g3c-aaa-emudhra.we-have-a-problem.com": [
|
||||
"9200d153c3cac272cc06bf68f9ea163fc24659fc3331e77668236fe00645ae52",
|
||||
"62b7f9868263f72f6911fc78ceaf72c1979158b7368bf648683bb2aa39b02e72"
|
||||
],
|
||||
"dv-g3c-emudhra.we-have-a-problem.com": [
|
||||
"346cdd962e97189b59aee5526d1ae8fa714bb1a64094dfa67bef768ee93f36b5",
|
||||
"0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"ev-g3b-aaa-emudhra.we-have-a-problem.com": [
|
||||
"bc88231a840e54cced148da48806169e8d0b1c0a96267a427aeccda9d10e6145",
|
||||
"579480aea166290bd3c643589e795fc0d0e54e86505d180f1dff99efd3199512"
|
||||
],
|
||||
"ev-g3b-emudhra.we-have-a-problem.com": [
|
||||
"66374ae64962c437bd815a2aae546c76e30c2eafac8335192c8e517c15a246ed",
|
||||
"e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"ev-g3c-aaa-emudhra.we-have-a-problem.com": [
|
||||
"bcd6ab59ea95fd520bfe7709679b1f682abbd79c77367e84e25415b91de37753",
|
||||
"fd9d5c2dbbd4995ab69b8d50c439d9527b00529199c2fd01b2174eb58f07021f"
|
||||
],
|
||||
"ev-g3c-emudhra.we-have-a-problem.com": [
|
||||
"ccd80467f9cac38f115dcb6e4f57e4d74f5efa519814ea934aeb3be53b83a191",
|
||||
"432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"ov-g3b-aaa-emudhra.we-have-a-problem.com": [
|
||||
"21f57f83631a93c98e867050075746b3361e0433c9e0e969462852476784f6f0",
|
||||
"5f1ded4dd590af74631290cf073b122a2aac6a1fa479778a0bd43579bd832699"
|
||||
],
|
||||
"ov-g3b-emudhra.we-have-a-problem.com": [
|
||||
"f3c3c15bc7edf2b3ebdc922af112f2a0c414eb509901099ef0488c0942e77efe",
|
||||
"98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"ov-g3c-aaa-emudhra.we-have-a-problem.com": [
|
||||
"59cf0386b56bc2414a0c06241ea9e25e01a9c93bac7a596648747f9d0ded4906",
|
||||
"7485b51c0ed192878fa1d2f948fb529ae1842cc5086c4b67f7be5b2039de9489"
|
||||
],
|
||||
"ov-g3c-emudhra.we-have-a-problem.com": [
|
||||
"4314941cf242a4ea4149aa7e9cdf77a15de2771a79984b809fc04443fea013c8",
|
||||
"cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
]
|
||||
},
|
||||
"cross_signed_roots": {
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd": "emSign Root TLS CA - G3 (emSign ECC Root CA - G3 cross-sign)"
|
||||
}
|
||||
}
|
||||
@@ -123,12 +123,88 @@ def test_blocks_listing_the_data_directory_itself():
|
||||
_resolve_tool_path(DATA_DIR)
|
||||
|
||||
|
||||
def test_blocks_app_state_reached_by_relative_path(monkeypatch):
|
||||
"""The data directory is a relative hop from the checkout root, so
|
||||
def test_blocks_app_state_reached_by_relative_path(tmp_path, monkeypatch):
|
||||
"""The data directory is a relative hop from the agent workspace, so
|
||||
confinement cannot depend on the model supplying an absolute path."""
|
||||
monkeypatch.chdir(os.path.dirname(DATA_DIR))
|
||||
current_execution = importlib.import_module("src.tool_execution")
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
workspace = _configure_test_data_tree(monkeypatch, data_dir)["AGENT_WORKSPACE_DIR"]
|
||||
workspace.mkdir()
|
||||
monkeypatch.setattr(current_execution, "_AGENT_WORKDIR", str(workspace))
|
||||
with pytest.raises(ValueError, match="application state"):
|
||||
_resolve_tool_path(os.path.join(os.path.basename(DATA_DIR), "sessions.json"))
|
||||
current_execution._resolve_tool_path(os.path.join("..", "sessions.json"))
|
||||
|
||||
|
||||
def test_relative_path_resolves_in_agent_workspace_not_server_cwd(tmp_path, monkeypatch):
|
||||
"""With no workspace bound, bash runs in the agent workspace, so a
|
||||
relative write_file/read_file path must land there too (#6679), not in
|
||||
the server's cwd, which is outside the allowed roots."""
|
||||
current_execution = importlib.import_module("src.tool_execution")
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
workspace = _configure_test_data_tree(monkeypatch, data_dir)["AGENT_WORKSPACE_DIR"]
|
||||
workspace.mkdir()
|
||||
monkeypatch.setattr(current_execution, "_AGENT_WORKDIR", str(workspace))
|
||||
server_cwd = tmp_path / "checkout"
|
||||
server_cwd.mkdir()
|
||||
monkeypatch.chdir(server_cwd)
|
||||
token = current_execution._active_workspace.set(None)
|
||||
try:
|
||||
assert current_execution._resolve_tool_path("test.txt") == os.path.join(
|
||||
os.path.realpath(workspace), "test.txt"
|
||||
)
|
||||
assert current_execution._resolve_search_root("sub") == os.path.join(
|
||||
os.path.realpath(workspace), "sub"
|
||||
)
|
||||
finally:
|
||||
current_execution._active_workspace.reset(token)
|
||||
|
||||
|
||||
def test_request_without_workspace_seals_the_agent_workspace(tmp_path, monkeypatch):
|
||||
"""With no workspace, the native file tools get the folder bash/python
|
||||
already run in (#6679) instead of no root at all, and a relative path
|
||||
lands there while app state next to it stays out of reach."""
|
||||
from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority
|
||||
from src.agent_runtime.resource_binding import resolve_filesystem_operation
|
||||
current_execution = importlib.import_module("src.tool_execution")
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
workspace = _configure_test_data_tree(monkeypatch, data_dir)["AGENT_WORKSPACE_DIR"]
|
||||
workspace.mkdir()
|
||||
(data_dir / "sessions.json").write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setattr(current_execution, "_AGENT_WORKDIR", str(workspace))
|
||||
|
||||
authority = RequestAuthority("request", "alice", "s", "", (OperationGrant("write_file"),))
|
||||
assert [r.path for r in authority.resource_roots] == [os.path.realpath(workspace)]
|
||||
|
||||
bound = resolve_filesystem_operation(
|
||||
ExactOperation.normalize("write_file", "test.txt\nhello"), roots=authority.resource_roots)
|
||||
assert bound.bindings[0].resource.path == os.path.join(os.path.realpath(workspace), "test.txt")
|
||||
with pytest.raises(ValueError, match="outside the sealed roots"):
|
||||
resolve_filesystem_operation(
|
||||
ExactOperation.normalize("read_file", "../sessions.json"), roots=authority.resource_roots)
|
||||
|
||||
|
||||
def test_symlinked_agent_workspace_is_not_sealed(tmp_path, monkeypatch):
|
||||
"""A symlinked agent workspace must not become the file-tool root:
|
||||
agent_cwd() and create_directories() refuse the same layout."""
|
||||
from src.agent_runtime.authority import OperationGrant, RequestAuthority
|
||||
current_execution = importlib.import_module("src.tool_execution")
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
workspace = _configure_test_data_tree(monkeypatch, data_dir)["AGENT_WORKSPACE_DIR"]
|
||||
elsewhere = tmp_path / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
(elsewhere / "notes.txt").write_text("private", encoding="utf-8")
|
||||
try:
|
||||
workspace.symlink_to(elsewhere, target_is_directory=True)
|
||||
except OSError:
|
||||
pytest.skip("cannot create symlink")
|
||||
monkeypatch.setattr(current_execution, "_AGENT_WORKDIR", str(workspace))
|
||||
|
||||
authority = RequestAuthority("request", "alice", "s", "", (OperationGrant("read_file"),))
|
||||
assert authority.resource_roots == ()
|
||||
|
||||
|
||||
def test_blocks_app_state_reached_through_a_symlink(tmp_path):
|
||||
|
||||
@@ -281,7 +281,8 @@ async def test_browser_approval_application_binding_is_exact(producer, field, va
|
||||
original = authority()
|
||||
content = '{"action":"session_info"}'
|
||||
approval = approval_for(original, "private_browser", content)
|
||||
changed = replace(original, **{field: value}, browser_sessions=(), browser_pages=())
|
||||
# resource_roots=None re-seals the root for the changed owner.
|
||||
changed = replace(original, **{field: value}, browser_sessions=(), browser_pages=(), resource_roots=None)
|
||||
_, result = await dispatch(changed, "private_browser", content, approval)
|
||||
assert result["exit_code"] == 1 and not approval._claimed
|
||||
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Offline regressions for incident roots, unaffected roots, and CA directories."""
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SPEC = importlib.util.spec_from_file_location("check_ca_trust", ROOT / "scripts/check_ca_trust.py")
|
||||
checker = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(checker)
|
||||
INCIDENT = json.loads((ROOT / "tests/fixtures/ca-trust/incident.json").read_text())
|
||||
|
||||
|
||||
class CATrustTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# Public certificate data is embedded below. Tests create temporary
|
||||
# PEM files from these constants; no fixture contains a private key.
|
||||
cls.temporary = tempfile.TemporaryDirectory()
|
||||
cls.fixtures = Path(cls.temporary.name)
|
||||
for name, pem in PUBLIC_CERTIFICATES.items():
|
||||
(cls.fixtures / name).write_text(pem)
|
||||
for fingerprint, pem in INCIDENT["certificates"].items():
|
||||
if hashlib.sha256(ssl.PEM_cert_to_DER_cert(pem)).hexdigest() != fingerprint:
|
||||
raise ValueError("incident fixture fingerprint mismatch")
|
||||
(cls.fixtures / f"{fingerprint}.pem").write_text(pem)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.temporary.cleanup()
|
||||
|
||||
def test_unaffected_emsign_root_remains_usable(self):
|
||||
checker.check_bundle(self.fixtures / "emsign-g1.pem")
|
||||
|
||||
def test_verbose_python_check_reports_and_rejects_affected_user_bundle(self):
|
||||
# A fresh interpreter must see persisted user packages, independently
|
||||
# of pytest's imported certifi/pip modules and any active test venv.
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
home = Path(tmp)
|
||||
env = dict(os.environ, HOME=str(home), PYTHONUSERBASE=str(home / ".local"))
|
||||
env.pop("PYTHONNOUSERSITE", None)
|
||||
env.pop("PYTHONPATH", None)
|
||||
python = getattr(sys, "_base_executable", sys.executable)
|
||||
site_result = subprocess.run(
|
||||
[python, "-m", "site", "--user-site"], env=env, capture_output=True, text=True,
|
||||
)
|
||||
self.assertEqual(site_result.returncode, 0, site_result.stderr)
|
||||
user_site = Path(site_result.stdout.strip())
|
||||
for package in ("certifi", "pip", "pip/_vendor", "pip/_vendor/certifi"):
|
||||
directory = user_site / package
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
(directory / "__init__.py").write_text(
|
||||
"from pathlib import Path\ndef where(): return str(Path(__file__).with_name('cacert.pem'))\n"
|
||||
)
|
||||
application_bundle = user_site / "certifi/cacert.pem"
|
||||
pip_bundle = user_site / "pip/_vendor/certifi/cacert.pem"
|
||||
for bundle in (application_bundle, pip_bundle):
|
||||
bundle.write_text(PUBLIC_CERTIFICATES["emsign-g1.pem"])
|
||||
command = [python, str(ROOT / "scripts/check_ca_trust.py"), "--python", "--verbose"]
|
||||
clean = subprocess.run(command, env=env, capture_output=True, text=True)
|
||||
self.assertEqual(clean.returncode, 0, clean.stderr)
|
||||
self.assertIn(str(application_bundle), clean.stdout)
|
||||
self.assertIn(str(pip_bundle), clean.stdout)
|
||||
self.assertIn(str(home), clean.stdout)
|
||||
self.assertIn("Python executable:", clean.stdout)
|
||||
application_bundle.write_text(PUBLIC_CERTIFICATES["emsign-g3.pem"])
|
||||
affected = subprocess.run(command, env=env, capture_output=True, text=True)
|
||||
self.assertEqual(affected.returncode, 1, affected.stderr)
|
||||
self.assertIn(str(application_bundle), affected.stderr)
|
||||
self.assertIn("emSign ECC Root CA - G3", affected.stderr)
|
||||
|
||||
def test_compromised_emsign_path_is_rejected(self):
|
||||
with self.assertRaisesRegex(ValueError, "emSign ECC Root CA - G3"):
|
||||
checker.check_bundle(self.fixtures / "emsign-g3.pem")
|
||||
|
||||
def test_aaa_cross_sign_path_is_rejected(self):
|
||||
with self.assertRaisesRegex(ValueError, "AAA Certificate Services"):
|
||||
checker.check_bundle(self.fixtures / "aaa.pem")
|
||||
|
||||
def test_mixed_bundle_does_not_hide_an_affected_root(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
bundle = Path(tmp) / "mixed.pem"
|
||||
bundle.write_bytes((self.fixtures / "emsign-g1.pem").read_bytes() + (self.fixtures / "aaa.pem").read_bytes())
|
||||
with self.assertRaisesRegex(ValueError, "AAA Certificate Services"):
|
||||
checker.check_bundle(bundle)
|
||||
|
||||
def test_all_published_intermediates_and_cross_signs_are_rejected(self):
|
||||
self.assertEqual(len(INCIDENT["intermediates"]), 16)
|
||||
for fingerprint, name in INCIDENT["intermediates"].items():
|
||||
with self.subTest(certificate=name), self.assertRaisesRegex(ValueError, "affected CA certificates"):
|
||||
checker.check_bundle(self.fixtures / f"{fingerprint}.pem")
|
||||
|
||||
def test_published_roots_and_cross_signed_issuer_are_rejected(self):
|
||||
certificates = INCIDENT["roots"] | INCIDENT["cross_signed_roots"]
|
||||
self.assertEqual(len(certificates), 5)
|
||||
for fingerprint, name in certificates.items():
|
||||
with self.subTest(certificate=name), self.assertRaises(ValueError):
|
||||
checker.check_bundle(self.fixtures / f"{fingerprint}.pem")
|
||||
|
||||
def test_clean_roots_do_not_hide_an_added_compromised_intermediate(self):
|
||||
fingerprint = next(iter(INCIDENT["intermediates"]))
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
bundle = Path(tmp) / "custom.pem"
|
||||
bundle.write_text(PUBLIC_CERTIFICATES["emsign-g1.pem"] + INCIDENT["certificates"][fingerprint])
|
||||
with self.assertRaisesRegex(ValueError, "EM DV TLS CA - G3B"):
|
||||
checker.check_bundle(bundle)
|
||||
|
||||
@unittest.skipUnless(shutil.which("openssl"), "offline chain verification requires OpenSSL")
|
||||
def test_published_chains_and_custom_anchor_with_affected_positive_control(self):
|
||||
# Freeze time inside the public fixtures' validity interval. Otherwise
|
||||
# expiry could make an ineffective trust-store fix appear successful.
|
||||
help_result = subprocess.run(["openssl", "verify", "-help"], capture_output=True, text=True)
|
||||
no_store = ["-no-CAstore"] if "-no-CAstore" in help_result.stdout + help_result.stderr else []
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
directory = Path(tmp)
|
||||
affected = directory / "affected.pem"
|
||||
affected.write_text("".join(INCIDENT["certificates"][key] for key in INCIDENT["roots"]))
|
||||
clean = self.fixtures / "emsign-g1.pem"
|
||||
custom = directory / "custom.pem"
|
||||
fingerprint = next(iter(INCIDENT["intermediates"]))
|
||||
custom.write_text(clean.read_text() + INCIDENT["certificates"][fingerprint])
|
||||
self.assertEqual(len(INCIDENT["chains"]), 14)
|
||||
custom_accepted = []
|
||||
for hostname, chain in INCIDENT["chains"].items():
|
||||
leaf, intermediates = directory / "leaf.pem", directory / "chain.pem"
|
||||
leaf.write_text(INCIDENT["certificates"][chain[0]])
|
||||
intermediates.write_text("".join(INCIDENT["certificates"][key] for key in chain[1:]))
|
||||
|
||||
def verify(bundle):
|
||||
return subprocess.run([
|
||||
"openssl", "verify", "-no-CApath", *no_store,
|
||||
"-attime", str(INCIDENT["verification_time"]),
|
||||
"-partial_chain", "-x509_strict", "-purpose", "sslserver",
|
||||
"-verify_hostname", hostname, "-CAfile", str(bundle),
|
||||
"-untrusted", str(intermediates), str(leaf),
|
||||
], capture_output=True, text=True)
|
||||
|
||||
with self.subTest(hostname=hostname):
|
||||
positive = verify(affected)
|
||||
self.assertEqual(positive.returncode, 0, positive.stderr)
|
||||
negative = verify(clean)
|
||||
self.assertNotEqual(negative.returncode, 0)
|
||||
self.assertIn("unable to get local issuer certificate", negative.stderr)
|
||||
if verify(custom).returncode == 0:
|
||||
custom_accepted.append(hostname)
|
||||
self.assertCountEqual(custom_accepted, [
|
||||
"dv-g3b-aaa-emudhra.we-have-a-problem.com",
|
||||
"dv-g3b-aaa-noct-emudhra.we-have-a-problem.com",
|
||||
"dv-g3b-emudhra.we-have-a-problem.com",
|
||||
"dv-g3b-noct-emudhra.we-have-a-problem.com",
|
||||
])
|
||||
checker.check_bundle(clean)
|
||||
for bundle in (affected, custom):
|
||||
with self.subTest(bundle=bundle.name), self.assertRaises(ValueError):
|
||||
checker.check_bundle(bundle)
|
||||
|
||||
def test_empty_malformed_and_missing_bundles_fail_closed(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for content in ("", "not a certificate"):
|
||||
path = Path(tmp) / "bad.pem"
|
||||
path.write_text(content)
|
||||
with self.subTest(content=content), self.assertRaises((ssl.SSLError, ValueError)):
|
||||
checker.check_bundle(path)
|
||||
with self.assertRaises(OSError):
|
||||
checker.check_bundle(Path(tmp) / "missing.pem")
|
||||
|
||||
def test_each_configured_override_is_checked(self):
|
||||
fingerprint = next(iter(INCIDENT["intermediates"]))
|
||||
affected = self.fixtures / f"{fingerprint}.pem"
|
||||
clean = self.fixtures / "emsign-g1.pem"
|
||||
for name in ("LLM_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE",
|
||||
"PIP_CERT", "NODE_EXTRA_CA_CERTS", "GRPC_DEFAULT_SSL_ROOTS_FILE_PATH"):
|
||||
for bundle, expected in ((clean, 0), (affected, 1)):
|
||||
with self.subTest(variable=name, bundle=bundle.name), patch.dict(
|
||||
os.environ, {name: str(bundle)}, clear=True
|
||||
), contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--overrides", str(clean)]), expected)
|
||||
|
||||
def test_invalid_override_is_not_hidden_by_a_clean_explicit_bundle(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
directory = Path(tmp)
|
||||
for name, content in (("empty.pem", ""), ("malformed.pem", "invalid")):
|
||||
(directory / name).write_text(content)
|
||||
for name in ("missing.pem", "empty.pem", "malformed.pem"):
|
||||
with self.subTest(file=name), patch.dict(
|
||||
os.environ, {"LLM_CA_BUNDLE": str(directory / name)}, clear=True
|
||||
), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--overrides", str(self.fixtures / "emsign-g1.pem")]), 1)
|
||||
|
||||
def test_no_configured_overrides_does_not_report_validated_stores(self):
|
||||
with patch.dict(os.environ, {"LLM_CA_BUNDLE": " "}, clear=True), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--overrides"]), 1)
|
||||
|
||||
def test_requests_directory_override_checks_hashed_intermediates(self):
|
||||
fingerprint = next(iter(INCIDENT["intermediates"]))
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
directory = Path(tmp)
|
||||
for name in ("REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "PIP_CERT"):
|
||||
with self.subTest(variable=name), patch.dict(os.environ, {name: str(directory)}, clear=True):
|
||||
for content, expected in ((PUBLIC_CERTIFICATES["emsign-g1.pem"], 0),
|
||||
(INCIDENT["certificates"][fingerprint], 1)):
|
||||
(directory / "12345678.0").write_text(content)
|
||||
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--overrides"]), expected)
|
||||
(directory / "12345678.0").unlink()
|
||||
with contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--overrides"]), 1)
|
||||
|
||||
def test_safe_cafile_does_not_hide_stale_hashed_directory(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
directory = Path(tmp)
|
||||
(directory / "14bc7599.0").symlink_to(self.fixtures / "emsign-g3.pem")
|
||||
defaults = ssl.get_default_verify_paths()._replace(
|
||||
cafile=str(self.fixtures / "emsign-g1.pem"), capath=str(directory)
|
||||
)
|
||||
errors = io.StringIO()
|
||||
with patch.object(checker.ssl, "get_default_verify_paths", return_value=defaults), patch.dict(
|
||||
os.environ, {defaults.openssl_capath_env: str(directory)}
|
||||
):
|
||||
with contextlib.redirect_stderr(errors):
|
||||
result = checker.main(["--system"])
|
||||
self.assertEqual(result, 1)
|
||||
self.assertIn("emSign ECC Root CA - G3", errors.getvalue())
|
||||
|
||||
def test_hashed_non_ca_certificate_does_not_reject_clean_store(self):
|
||||
# Ubuntu's OpenSSL directory can also hash ssl-cert-snakeoil.pem.
|
||||
# A valid server certificate has no CA entries in get_ca_certs().
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
directory = Path(tmp)
|
||||
(directory / "ce275665.0").symlink_to(self.fixtures / "localhost-leaf.pem")
|
||||
defaults = ssl.get_default_verify_paths()._replace(
|
||||
cafile=str(self.fixtures / "emsign-g1.pem"), capath=str(directory)
|
||||
)
|
||||
with patch.object(checker.ssl, "get_default_verify_paths", return_value=defaults), patch.dict(
|
||||
os.environ, {defaults.openssl_capath_env: str(directory)}
|
||||
), contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--system"]), 0)
|
||||
|
||||
def test_multiple_ca_directories_are_all_checked(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
clean, stale = Path(tmp) / "clean", Path(tmp) / "stale"
|
||||
clean.mkdir()
|
||||
stale.mkdir()
|
||||
(stale / "ee64a828.0").symlink_to(self.fixtures / "aaa.pem")
|
||||
defaults = ssl.get_default_verify_paths()._replace(
|
||||
cafile=str(self.fixtures / "emsign-g1.pem"), capath=None
|
||||
)
|
||||
errors = io.StringIO()
|
||||
with patch.object(checker.ssl, "get_default_verify_paths", return_value=defaults), patch.dict(
|
||||
os.environ, {defaults.openssl_capath_env: os.pathsep.join(map(str, (clean, stale)))}
|
||||
):
|
||||
with contextlib.redirect_stderr(errors):
|
||||
result = checker.main(["--system"])
|
||||
self.assertEqual(result, 1)
|
||||
self.assertIn("AAA Certificate Services", errors.getvalue())
|
||||
|
||||
def test_grpc_bundled_roots_are_checked_without_an_override(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
package = Path(temporary) / "grpc"
|
||||
bundle = package / "_cython" / "_credentials" / "roots.pem"
|
||||
bundle.parent.mkdir(parents=True)
|
||||
bundle.write_bytes((self.fixtures / "emsign-g3.pem").read_bytes())
|
||||
errors = io.StringIO()
|
||||
with patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": ""}), patch.object(
|
||||
checker.importlib.util, "find_spec", return_value=SimpleNamespace(origin=str(package / "__init__.py"))
|
||||
), contextlib.redirect_stderr(errors):
|
||||
self.assertEqual(checker.main(["--grpc"]), 1)
|
||||
self.assertIn("emSign ECC Root CA - G3", errors.getvalue())
|
||||
|
||||
def test_grpc_explicit_clean_store_replaces_the_independent_bundle(self):
|
||||
with patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": str(self.fixtures / "emsign-g1.pem")}), patch.object(
|
||||
checker.importlib.util, "find_spec", side_effect=AssertionError("must use the explicit store")
|
||||
), contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--grpc"]), 0)
|
||||
|
||||
def test_grpc_invalid_override_does_not_fall_back_silently(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
for name, content in (("empty.pem", ""), ("malformed.pem", "not a certificate"), ("affected.pem", PUBLIC_CERTIFICATES["aaa.pem"])):
|
||||
bundle = Path(temporary) / name
|
||||
bundle.write_text(content)
|
||||
with self.subTest(name=name), patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": str(bundle)}), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--grpc"]), 1)
|
||||
with patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": str(Path(temporary) / "missing.pem")}), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--grpc"]), 1)
|
||||
|
||||
def test_grpc_check_reports_a_missing_installation(self):
|
||||
with patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": ""}), patch.object(
|
||||
checker.importlib.util, "find_spec", return_value=None
|
||||
), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--grpc"]), 1)
|
||||
|
||||
|
||||
# Public Mozilla CA certificates; provenance: tests/fixtures/ca-trust/README.md.
|
||||
PUBLIC_CERTIFICATES = {
|
||||
'localhost-leaf.pem': """-----BEGIN CERTIFICATE-----
|
||||
MIIBOTCB7KADAgECAhRcVxnOQNEFWf92K4dm4I8ChyjVMzAFBgMrZXAwFDESMBAG
|
||||
A1UEAwwJbG9jYWxob3N0MB4XDTI2MTAyMTE4NTgwN1oXDTM2MTAxODE4NTgwN1ow
|
||||
FDESMBAGA1UEAwwJbG9jYWxob3N0MCowBQYDK2VwAyEARglphvNgp9qRCESLHnuu
|
||||
W8VC5urdZl+YgS+Ux1BoLryjUDBOMB0GA1UdDgQWBBSaEckyR/76lX3a5Ijh8B42
|
||||
oJwN1DAfBgNVHSMEGDAWgBSaEckyR/76lX3a5Ijh8B42oJwN1DAMBgNVHRMBAf8E
|
||||
AjAAMAUGAytlcANBAJkq9ayy+d4AUZnjhWM31rFke5DT+l9QFcPl34+dpVhqJV/L
|
||||
m4IpPHYagqrBRHn9Dv/C8qYyPBKnAwJRpNptsgw=
|
||||
-----END CERTIFICATE-----
|
||||
""",
|
||||
'aaa.pem': """-----BEGIN CERTIFICATE-----
|
||||
MIIEMjCCAxqgAwIBAgIBATANBgkqhkiG9w0BAQUFADB7MQswCQYDVQQGEwJHQjEb
|
||||
MBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRow
|
||||
GAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UEAwwYQUFBIENlcnRpZmlj
|
||||
YXRlIFNlcnZpY2VzMB4XDTA0MDEwMTAwMDAwMFoXDTI4MTIzMTIzNTk1OVowezEL
|
||||
MAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UE
|
||||
BwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNVBAMM
|
||||
GEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczCCASIwDQYJKoZIhvcNAQEBBQADggEP
|
||||
ADCCAQoCggEBAL5AnfRu4ep2hxxNRUSOvkbIgwadwSr+GB+O5AL686tdUIoWMQua
|
||||
BtDFcCLNSS1UY8y2bmhGC1Pqy0wkwLxyTurxFa70VJoSCsN6sjNg4tqJVfMiWPPe
|
||||
3M/vg4aijJRPn2jymJBGhCfHdr/jzDUsi14HZGWCwEiwqJH5YZ92IFCokcdmtet4
|
||||
YgNW8IoaE+oxox6gmf049vYnMlhvB/VruPsUK6+3qszWY19zjNoFmag4qMsXeDZR
|
||||
rOme9Hg6jc8P2ULimAyrL58OAd7vn5lJ8S3frHRNG5i1R8XlKdH5kBjHYpy+g8cm
|
||||
ez6KJcfA3Z3mNWgQIJ2P2N7Sw4ScDV7oL8kCAwEAAaOBwDCBvTAdBgNVHQ4EFgQU
|
||||
oBEKIz6W8Qfs4q8p74Klf9AwpLQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF
|
||||
MAMBAf8wewYDVR0fBHQwcjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20v
|
||||
QUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNqA0oDKGMGh0dHA6Ly9jcmwuY29t
|
||||
b2RvLm5ldC9BQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDANBgkqhkiG9w0BAQUF
|
||||
AAOCAQEACFb8AvCb6P+k+tZ7xkSAzk/ExfYAWMymtrwUSWgEdujm7l3sAg9g1o1Q
|
||||
GE8mTgHj5rCl7r+8dFRBv/38ErjHT1r0iWAFf2C3BUrz9vHCv8S5dIa2LX1rzNLz
|
||||
Rt0vxuBqw8M0Ayx9lt1awg6nCpnBBYurDC/zXDrPbDdVCYfeU0BsWO/8tqtlbgT2
|
||||
G9w84FoVxp7Z8VlIMCFlA2zs6SFz7JsDoeA3raAVGI/6ugLOpyypEBMs1OUIJqsi
|
||||
l2D4kF501KKaU73yqWjgom7C12yxow+ev+to51byrvLjKzg6CYG1a4XXvi3tPxq3
|
||||
smPi9WIsgtRqAEFQ8TmDn5XpNpaYbg==
|
||||
-----END CERTIFICATE-----
|
||||
""",
|
||||
'emsign-g1.pem': """-----BEGIN CERTIFICATE-----
|
||||
MIIDlDCCAnygAwIBAgIKMfXkYgxsWO3W2DANBgkqhkiG9w0BAQsFADBnMQswCQYD
|
||||
VQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBU
|
||||
ZWNobm9sb2dpZXMgTGltaXRlZDEcMBoGA1UEAxMTZW1TaWduIFJvb3QgQ0EgLSBH
|
||||
MTAeFw0xODAyMTgxODMwMDBaFw00MzAyMTgxODMwMDBaMGcxCzAJBgNVBAYTAklO
|
||||
MRMwEQYDVQQLEwplbVNpZ24gUEtJMSUwIwYDVQQKExxlTXVkaHJhIFRlY2hub2xv
|
||||
Z2llcyBMaW1pdGVkMRwwGgYDVQQDExNlbVNpZ24gUm9vdCBDQSAtIEcxMIIBIjAN
|
||||
BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAk0u76WaK7p1b1TST0Bsew+eeuGQz
|
||||
f2N4aLTNLnF115sgxk0pvLZoYIr3IZpWNVrzdr3YzZr/k1ZLpVkGoZM0Kd0WNHVO
|
||||
8oG0x5ZOrRkVUkr+PHB1cM2vK6sVmjM8qrOLqs1D/fXqcP/tzxE7lM5OMhbTI0Aq
|
||||
d7OvPAEsbO2ZLIvZTmmYsvePQbAyeGHWDV/D+qJAkh1cF+ZwPjXnorfCYuKrpDhM
|
||||
tTk1b+oDafo6VGiFbdbyL0NVHpENDtjVaqSW0RM8LHhQ6DqS0hdW5TUaQBw+jSzt
|
||||
Od9C4INBdN+jzcKGYEho42kLVACL5HZpIQ15TjQIXhTCzLG3rdd8cIrHhQIDAQAB
|
||||
o0IwQDAdBgNVHQ4EFgQU++8Nhp6w492pufEhF38+/PB3KxowDgYDVR0PAQH/BAQD
|
||||
AgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAFn/8oz1h31x
|
||||
PaOfG1vR2vjTnGs2vZupYeveFix0PZ7mddrXuqe8QhfnPZHr5X3dPpzxz5KsbEjM
|
||||
wiI/aTvFthUvozXGaCocV685743QNcMYDHsAVhzNixl03r4PEuDQqqE/AjSxcM6d
|
||||
GNYIAwlG7mDgfrbESQRRfXBgvKqy/3lyeqYdPV8q+Mri/Tm3R7nrft8EI6/6nAYH
|
||||
6ftjk4BAtcZsCjEozgyfz7MjNYBBjWzEN3uBL4ChQEKF6dk4jeihU80Bv2noWgby
|
||||
RQuQ+q7hv53yrlc8pa6yVvSLZUDp/TGBLPQ5Cdjua6e0ph0VpZj3AYHYhX3zUVxx
|
||||
iN66zB+Afko=
|
||||
-----END CERTIFICATE-----
|
||||
""",
|
||||
'emsign-g3.pem': """-----BEGIN CERTIFICATE-----
|
||||
MIICTjCCAdOgAwIBAgIKPPYHqWhwDtqLhDAKBggqhkjOPQQDAzBrMQswCQYDVQQG
|
||||
EwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNo
|
||||
bm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0g
|
||||
RzMwHhcNMTgwMjE4MTgzMDAwWhcNNDMwMjE4MTgzMDAwWjBrMQswCQYDVQQGEwJJ
|
||||
TjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9s
|
||||
b2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0gRzMw
|
||||
djAQBgcqhkjOPQIBBgUrgQQAIgNiAAQjpQy4LRL1KPOxst3iAhKAnjlfSU2fySU0
|
||||
WXTsuwYc58Byr+iuL+FBVIcUqEqy6HyC5ltqtdyzdc6LBtCGI79G1Y4PPwT01xyS
|
||||
fvalY8L1X44uT6EYGQIrMgqCZH0Wk9GjQjBAMB0GA1UdDgQWBBR8XQKEE9TMipuB
|
||||
zhccLikenEhjQjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggq
|
||||
hkjOPQQDAwNpADBmAjEAvvNhzwIQHWSVB7gYboiFBS+DCBeQyh+KTOgNG3qxrdWB
|
||||
CUfvO6wIBHxcmbHtRwfSAjEAnbpV/KlK6O3t5nYBQnvI+GDZjVGLVTv7jHvrZQnD
|
||||
+JbNR6iC8hZVdyR+EhCVBCyj
|
||||
-----END CERTIFICATE-----
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,41 @@
|
||||
"""The managed browser policy must distrust exactly the incident's eight keys."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
# Public incident inventory: impacted-intermediates.json, 2026-10-21.
|
||||
COMPROMISED_SPKIS = {
|
||||
"43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b",
|
||||
"49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f",
|
||||
"5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0",
|
||||
"63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8",
|
||||
"92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30",
|
||||
"e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41",
|
||||
"ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105",
|
||||
"ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6",
|
||||
}
|
||||
|
||||
|
||||
class ChromiumCATrustTests(unittest.TestCase):
|
||||
def test_policy_distrusts_each_compromised_key_without_adding_trust(self):
|
||||
policy = json.loads((ROOT / "docker" / "chromium-emudhra.json").read_text())
|
||||
self.assertEqual(set(policy), {"CADistrustedCertificates"})
|
||||
self.assertEqual(len(policy["CADistrustedCertificates"]), 8)
|
||||
keys = set()
|
||||
for encoded in policy["CADistrustedCertificates"]:
|
||||
certificate = x509.load_der_x509_certificate(base64.b64decode(encoded, validate=True))
|
||||
self.assertTrue(certificate.extensions.get_extension_for_class(x509.BasicConstraints).value.ca)
|
||||
self.assertNotEqual(certificate.subject, certificate.issuer)
|
||||
public_key = certificate.public_key().public_bytes(
|
||||
serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
keys.add(hashlib.sha256(public_key).hexdigest())
|
||||
self.assertEqual(keys, COMPROMISED_SPKIS)
|
||||
@@ -33,3 +33,16 @@ def test_python_tests_are_authoritative():
|
||||
|
||||
assert "python -m pytest -q" in python_tests
|
||||
assert "continue-on-error:" not in python_tests
|
||||
|
||||
|
||||
def test_ci_checks_system_trust_before_project_installation():
|
||||
python_tests = _indented_block(_WORKFLOW.read_text(), "python-tests", 2)
|
||||
system_check = python_tests.index("python scripts/check_ca_trust.py --system\n")
|
||||
system_update = python_tests.index("sudo apt-get install -y --no-install-recommends ffmpeg bubblewrap ca-certificates")
|
||||
bootstrap = python_tests.index("uv pip install --system --require-hashes")
|
||||
python_check = python_tests.index("python scripts/check_ca_trust.py --python\n")
|
||||
application_install = python_tests.index("uv pip install --system -r requirements.txt")
|
||||
|
||||
assert system_update < system_check < bootstrap < python_check < application_install
|
||||
assert system_check < python_tests.index("npm ci")
|
||||
assert system_check < python_tests.index("npx playwright install")
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Agent chat history uses the same scope as existing chat and history routes."""
|
||||
|
||||
import pytest
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from starlette.requests import Request
|
||||
|
||||
from routes import codex_routes
|
||||
|
||||
|
||||
def _endpoint(router, method: str, path: str):
|
||||
for route in router.routes:
|
||||
if route.path == path and method in route.methods:
|
||||
return route.endpoint
|
||||
raise AssertionError(f"{method} {path} route not found")
|
||||
|
||||
|
||||
def _request(scopes: list[str]) -> Request:
|
||||
request = Request({
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/api/codex/chat/session-test",
|
||||
"headers": [],
|
||||
"state": {},
|
||||
})
|
||||
request.state.current_user = "api"
|
||||
request.state.api_token = True
|
||||
request.state.api_token_owner = "alice"
|
||||
request.state.api_token_scopes = scopes
|
||||
return request
|
||||
|
||||
|
||||
def _history_router(calls: list[tuple]):
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/api/history/{session_id}")
|
||||
async def history(
|
||||
request: Request,
|
||||
session_id: str,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
):
|
||||
calls.append((request, session_id, limit, offset))
|
||||
return {
|
||||
"name": "Model debugging",
|
||||
"model": "org/model",
|
||||
"history": [{"role": "assistant", "content": "second"}],
|
||||
"total": 3,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"endpoint_url": "http://private-endpoint",
|
||||
}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_read_uses_existing_paged_handler_without_exposing_endpoint_url():
|
||||
calls = []
|
||||
router = codex_routes.setup_codex_routes(history_router=_history_router(calls))
|
||||
read_chat = _endpoint(router, "GET", "/api/codex/chat/{session_id}")
|
||||
request = _request(["chat"])
|
||||
|
||||
result = await read_chat(request, "session-test", offset=1, limit=1)
|
||||
|
||||
assert calls == [(request, "session-test", 1, 1)]
|
||||
assert result == {
|
||||
"session_id": "session-test",
|
||||
"name": "Model debugging",
|
||||
"model": "org/model",
|
||||
"messages": [{"role": "assistant", "content": "second"}],
|
||||
"total": 3,
|
||||
"offset": 1,
|
||||
"limit": 1,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_without_chat_scope_cannot_read_history_or_reach_handler():
|
||||
calls = []
|
||||
router = codex_routes.setup_codex_routes(history_router=_history_router(calls))
|
||||
read_chat = _endpoint(router, "GET", "/api/codex/chat/{session_id}")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await read_chat(_request(["todos:read"]), "session-test")
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_read_uses_existing_page_limit():
|
||||
calls = []
|
||||
router = codex_routes.setup_codex_routes(history_router=_history_router(calls))
|
||||
read_chat = _endpoint(router, "GET", "/api/codex/chat/{session_id}")
|
||||
|
||||
await read_chat(_request(["chat"]), "session-test", limit=1000)
|
||||
|
||||
assert calls[0][2:] == (100, 0)
|
||||
|
||||
|
||||
def test_capabilities_report_history_permission_from_chat_scope():
|
||||
router = codex_routes.setup_codex_routes(history_router=_history_router([]))
|
||||
capabilities = _endpoint(router, "GET", "/api/codex/capabilities")
|
||||
|
||||
without_chat = capabilities(_request(["todos:read"]))["tools"]["chat"]
|
||||
reader = capabilities(_request(["chat"]))["tools"]["chat"]
|
||||
|
||||
assert without_chat["read"] is False
|
||||
assert reader["read"] is True
|
||||
assert reader["available"] is True
|
||||
assert reader["actions"] == ["read"]
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Sidecar images must be pinned to a versioned tag and an immutable digest.
|
||||
|
||||
A bare or ``:latest`` reference changes underneath an existing install on the
|
||||
next ``docker compose pull``/``up``, so two hosts on the same commit can run
|
||||
different ChromaDB or ntfy builds. The ``odysseus`` service is excluded: it is
|
||||
the project's own image, selected through ``ODYSSEUS_IMAGE``. The standalone
|
||||
GPU files are held equal to these services by test_gpu_compose_standalone.py.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
COMPOSE = Path(__file__).resolve().parents[1] / "docker-compose.yml"
|
||||
PINNED = re.compile(r"^[\w.\-/]+:(?P<tag>[\w.\-]+)@sha256:[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def _sidecars():
|
||||
services = yaml.safe_load(COMPOSE.read_text(encoding="utf-8"))["services"]
|
||||
return {name: svc["image"] for name, svc in services.items() if name != "odysseus"}
|
||||
|
||||
|
||||
def test_expected_sidecars_present():
|
||||
assert {"chromadb", "searxng", "ntfy"} <= set(_sidecars())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", sorted(_sidecars()))
|
||||
def test_sidecar_image_has_version_tag_and_digest(name):
|
||||
image = _sidecars()[name]
|
||||
match = PINNED.match(image)
|
||||
assert match, f"{name}: expected <repo>:<tag>@sha256:<digest>, got {image!r}"
|
||||
assert match.group("tag") != "latest", f"{name}: pin a release tag, not latest"
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Read-only Cookbook tools must be admin-only for the agent.
|
||||
|
||||
Regression: `list_cached_models`, `list_served_models`, `tail_serve_output`,
|
||||
`list_downloads`, `search_hf_models`, `list_serve_presets` and
|
||||
`list_cookbook_servers` sat in the plan-mode read-only allowlist but not in
|
||||
NON_ADMIN_BLOCKED_TOOLS or the dispatcher's _ADMIN_TOOLS. Their
|
||||
implementations call require_admin routes over the internal loopback with the
|
||||
process-wide INTERNAL_TOOL_TOKEN and never look at the owner, so a non-admin
|
||||
user's agent could reach admin capability through them. The worst case is
|
||||
`list_cached_models`: a caller-chosen `host` is forwarded to
|
||||
/api/model/cached, which SSHes to that host with the app user's keys and
|
||||
walks caller-chosen `model_dir`s. THREAT_MODEL.md lists model serving as
|
||||
admin-only, so the read side has to be gated the same way as serve/download.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.tool_security import (
|
||||
NON_ADMIN_BLOCKED_TOOLS,
|
||||
blocked_tools_for_owner,
|
||||
delegated_credential_blocked_tools,
|
||||
)
|
||||
from src.tool_execution import _ADMIN_TOOLS
|
||||
from src.agent_runtime.authority import OperationGrant, RequestAuthority
|
||||
|
||||
READONLY_COOKBOOK_TOOLS = (
|
||||
"list_cached_models",
|
||||
"list_served_models",
|
||||
"tail_serve_output",
|
||||
"list_downloads",
|
||||
"search_hf_models",
|
||||
"list_serve_presets",
|
||||
"list_cookbook_servers",
|
||||
)
|
||||
|
||||
|
||||
def _install_core_auth_stub(monkeypatch, *, is_admin: bool):
|
||||
"""Narrow auth surface: AUTH enabled, configured, owner admin or not."""
|
||||
core_mod = types.ModuleType("core")
|
||||
core_mod.__path__ = []
|
||||
auth_mod = types.ModuleType("core.auth")
|
||||
|
||||
class FakeAuth:
|
||||
is_configured = True
|
||||
|
||||
def is_admin(self, username):
|
||||
return is_admin
|
||||
|
||||
auth_mod.AuthManager = lambda: FakeAuth()
|
||||
core_mod.auth = auth_mod
|
||||
monkeypatch.setitem(sys.modules, "core", core_mod)
|
||||
monkeypatch.setitem(sys.modules, "core.auth", auth_mod)
|
||||
monkeypatch.setattr("src.auth_helpers._auth_disabled", lambda: False)
|
||||
return auth_mod
|
||||
|
||||
|
||||
def test_readonly_cookbook_tools_are_in_every_admin_gate():
|
||||
from src.agent_loop import _ADMIN_TOOLS as prompt_admin_tools
|
||||
|
||||
# Spelled out (not imported from a shared tuple in src/) so dropping one
|
||||
# from either set fails here instead of silently reopening the hole.
|
||||
for tool in READONLY_COOKBOOK_TOOLS:
|
||||
assert tool in NON_ADMIN_BLOCKED_TOOLS, tool
|
||||
assert tool in _ADMIN_TOOLS, tool
|
||||
assert tool in prompt_admin_tools, tool
|
||||
|
||||
|
||||
def test_readonly_cookbook_tools_hidden_from_non_admin_and_bearer_runs(monkeypatch):
|
||||
_install_core_auth_stub(monkeypatch, is_admin=False)
|
||||
hidden = blocked_tools_for_owner("regular-user")
|
||||
for tool in READONLY_COOKBOOK_TOOLS:
|
||||
assert tool in hidden, tool
|
||||
assert tool in delegated_credential_blocked_tools(), tool
|
||||
|
||||
|
||||
def test_admin_owner_keeps_readonly_cookbook_tools(monkeypatch):
|
||||
_install_core_auth_stub(monkeypatch, is_admin=True)
|
||||
assert blocked_tools_for_owner("admin-user") == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("tool_name", READONLY_COOKBOOK_TOOLS)
|
||||
async def test_non_admin_agent_cannot_execute_readonly_cookbook_tools(monkeypatch, tool_name):
|
||||
_install_core_auth_stub(monkeypatch, is_admin=False)
|
||||
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT, execute_tool_block
|
||||
|
||||
# The loopback must never be touched: if the gate fails, the tool would
|
||||
# hit a require_admin route with the internal token.
|
||||
import httpx
|
||||
|
||||
def _boom(*args, **kwargs):
|
||||
raise AssertionError(f"{tool_name} reached the internal loopback for a non-admin owner")
|
||||
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _boom)
|
||||
monkeypatch.setattr(httpx, "get", _boom)
|
||||
monkeypatch.setattr(httpx, "post", _boom)
|
||||
|
||||
payload = '{"host": "root@10.0.0.5", "ssh_port": "22", "model_dir": "/root,/etc"}'
|
||||
desc, result = await execute_tool_block(
|
||||
SimpleNamespace(tool_type=tool_name, content=payload),
|
||||
owner="regular-user",
|
||||
security_context=NO_TOOL_SECURITY_CONTEXT,
|
||||
request_authority=RequestAuthority(
|
||||
"cookbook-test", "regular-user", "", "", (OperationGrant(tool_name),)
|
||||
),
|
||||
)
|
||||
assert desc == f"{tool_name}: BLOCKED"
|
||||
assert result["exit_code"] == 1
|
||||
assert "admin" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("tool_name", READONLY_COOKBOOK_TOOLS)
|
||||
async def test_admin_agent_can_dispatch_readonly_cookbook_tools(monkeypatch, tool_name):
|
||||
from src import tool_implementations
|
||||
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT, execute_tool_block
|
||||
|
||||
_install_core_auth_stub(monkeypatch, is_admin=True)
|
||||
handler = AsyncMock(return_value={"output": "Cookbook result", "exit_code": 0})
|
||||
monkeypatch.setattr(tool_implementations, f"do_{tool_name}", handler)
|
||||
desc, result = await execute_tool_block(
|
||||
SimpleNamespace(tool_type=tool_name, content="{}"),
|
||||
owner="admin-user",
|
||||
security_context=NO_TOOL_SECURITY_CONTEXT,
|
||||
request_authority=RequestAuthority(
|
||||
"cookbook-test", "admin-user", "", "", (OperationGrant(tool_name),)
|
||||
),
|
||||
)
|
||||
assert desc == tool_name
|
||||
assert result["exit_code"] == 0
|
||||
handler.assert_awaited_once_with("{}", owner="admin-user")
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Execute the publication workflow's image-name setup without contacting GHCR."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
WORKFLOW = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ ".github"
|
||||
/ "workflows"
|
||||
/ "docker-publish.yml"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("bash") is None, reason="Docker publication requires Bash")
|
||||
@pytest.mark.parametrize("job", ["build", "merge"])
|
||||
@pytest.mark.parametrize(
|
||||
("repository", "expected"),
|
||||
[
|
||||
("RaresKeY/odysseus-dev", "rareskey/odysseus-dev"),
|
||||
("ExampleOwner/Example-Repo", "exampleowner/example-repo"),
|
||||
("odysseus-dev/odysseus", "odysseus-dev/odysseus"),
|
||||
],
|
||||
)
|
||||
def test_image_name_is_lowercase_before_docker_steps(tmp_path, job, repository, expected):
|
||||
workflow = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))
|
||||
image_name = workflow["env"].get("IMAGE_NAME", "").replace(
|
||||
"${{ github.repository }}", repository
|
||||
)
|
||||
github_env = tmp_path / "github-env"
|
||||
env = {
|
||||
"PATH": os.environ["PATH"],
|
||||
"GITHUB_REPOSITORY": repository,
|
||||
"GITHUB_ENV": str(github_env),
|
||||
"GITHUB_OUTPUT": str(tmp_path / "github-output"),
|
||||
"GITHUB_SHA": "a" * 40,
|
||||
}
|
||||
|
||||
# Run the real setup shell before the job starts using Docker actions.
|
||||
# Checkout, login, builds and registry publication are deliberately excluded.
|
||||
for step in workflow["jobs"][job]["steps"]:
|
||||
if step.get("uses", "").startswith("docker/"):
|
||||
break
|
||||
if "run" not in step:
|
||||
continue
|
||||
subprocess.run(
|
||||
["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", step["run"]],
|
||||
cwd=WORKFLOW.parents[2],
|
||||
env=env,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if github_env.exists():
|
||||
env.update(line.split("=", 1) for line in github_env.read_text().splitlines())
|
||||
image_name = env.get("IMAGE_NAME", image_name)
|
||||
|
||||
assert image_name == expected
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Repository asset ownership guards for issues #1335 and #6175.
|
||||
|
||||
Public Markdown and landing-page media belong in website/, while shared
|
||||
README/packaging imagery belongs in assets/branding/. Images in either managed
|
||||
root must be referenced by tracked text, and every tracked website video must
|
||||
be referenced by the site's entry point.
|
||||
optional README/packaging imagery belongs in assets/branding/. Retained images
|
||||
in either managed root must be referenced by tracked text, and every retained
|
||||
website video must be referenced by the site's entry point. Publication can
|
||||
operate with no imagery or videos.
|
||||
"""
|
||||
import re
|
||||
import subprocess
|
||||
@@ -41,7 +42,8 @@ def _tracked(*paths_under):
|
||||
return None
|
||||
if out.returncode != 0:
|
||||
return None
|
||||
return [REPO / line for line in out.stdout.splitlines() if line.strip()]
|
||||
return [REPO / line for line in out.stdout.splitlines()
|
||||
if line.strip() and (REPO / line).is_file()]
|
||||
|
||||
|
||||
def test_no_orphan_documentation_or_branding_images():
|
||||
@@ -49,9 +51,6 @@ def test_no_orphan_documentation_or_branding_images():
|
||||
if managed_files is None:
|
||||
pytest.skip("not a git checkout")
|
||||
managed_images = [p for p in managed_files if p.suffix.lower() in IMAGE_EXTS]
|
||||
assert any("assets/branding" in p.as_posix() for p in managed_images), (
|
||||
"expected assets/branding/ to contain the shared project imagery"
|
||||
)
|
||||
|
||||
# All tracked text we might reference an image from.
|
||||
all_tracked = _tracked(".") or []
|
||||
@@ -94,7 +93,6 @@ def test_pages_site_owns_its_entrypoint_and_media():
|
||||
assert text.startswith("---\nlayout: default\n---\n"), guide
|
||||
|
||||
website_videos = [p for p in website_files if p.suffix.lower() in VIDEO_EXTS]
|
||||
assert website_videos, "expected website/ to contain the landing-page videos"
|
||||
|
||||
entrypoint = (REPO / "website/index.html").read_text(encoding="utf-8")
|
||||
unreferenced = [
|
||||
|
||||
@@ -39,6 +39,7 @@ def test_resolve_model_keeps_request_when_served_list_unknown():
|
||||
|
||||
|
||||
def test_self_check_resolves_the_model_before_calling(monkeypatch):
|
||||
monkeypatch.setattr(eam, "local_bases", lambda: ["http://editor.test/v1"])
|
||||
base = eam.local_bases()[0]
|
||||
_cache(base, ["Qwen/Qwen3.5-9B"])
|
||||
seen = {}
|
||||
@@ -56,6 +57,7 @@ def test_self_check_resolves_the_model_before_calling(monkeypatch):
|
||||
|
||||
|
||||
def test_caption_resolves_the_caption_model(monkeypatch):
|
||||
monkeypatch.setattr(eam, "caption_base", lambda: "http://caption.test/v1")
|
||||
seen = {}
|
||||
|
||||
async def fake_post(b, model, messages, headers, timeout, **kwargs):
|
||||
@@ -69,3 +71,13 @@ def test_caption_resolves_the_caption_model(monkeypatch):
|
||||
txt = asyncio.run(m.caption_image("data:image/png;base64,AAAA"))
|
||||
assert txt == "a photo"
|
||||
assert seen["model"] == "Qwen/Qwen3.5-9B"
|
||||
|
||||
|
||||
def test_unconfigured_editor_helpers_do_not_probe_private_hosts(monkeypatch):
|
||||
monkeypatch.setattr(eam, "local_bases", lambda: [])
|
||||
monkeypatch.setattr(eam, "caption_base", lambda: "")
|
||||
monkeypatch.setattr(eam, "probe_models", lambda *a: (_ for _ in ()).throw(AssertionError("unexpected probe")))
|
||||
eam._probe_worker()
|
||||
assert asyncio.run(eam.caption_image("data:image/png;base64,AAAA")) is None
|
||||
result = asyncio.run(eam.self_check("data:image/png;base64,AAAA", "data:image/png;base64,BBBB", "brighter"))
|
||||
assert result["reason"] == "unavailable"
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
"""User-supplied IMAP/SMTP hosts must pass the outbound host guard.
|
||||
|
||||
Regression: POST /api/email/accounts/test opened raw ``imaplib`` / ``smtplib``
|
||||
connections to whatever host:port the caller supplied, and the account save
|
||||
routes persisted arbitrary hosts for the background pollers to contact. Both
|
||||
are reachable by any authenticated user (``require_user`` / ``require_owner``,
|
||||
no admin gate), so a non-admin could port-scan loopback, the LAN and the cloud
|
||||
metadata range through the server and read the first 200 chars of the banner
|
||||
back out of the error message. THREAT_MODEL.md lists email as admin-only.
|
||||
|
||||
Policy (see routes/email_routes.py:_check_mail_host): link-local (metadata),
|
||||
multicast, reserved and unspecified targets are always rejected; loopback and
|
||||
private ranges are rejected for non-admin owners or when
|
||||
EMAIL_BLOCK_PRIVATE_IPS=true, and stay allowed for admins (local-first: a LAN
|
||||
Dovecot is a normal setup). Unresolved names are rejected before persistence;
|
||||
live connections also validate and pin their own resolved addresses.
|
||||
"""
|
||||
|
||||
import socket
|
||||
import unittest.mock as mock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.url_safety import check_outbound_host, connect_outbound_tcp, OutboundAddressBlocked
|
||||
|
||||
|
||||
# ── helper policy table ──────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("host", [
|
||||
"169.254.169.254",
|
||||
"[::ffff:169.254.169.254]",
|
||||
"::ffff:169.254.169.254",
|
||||
"fe80::1",
|
||||
"0.0.0.0",
|
||||
"224.0.0.1",
|
||||
"100.100.100.200",
|
||||
"[100.100.100.200]",
|
||||
])
|
||||
def test_always_blocked_addresses(host):
|
||||
ok, reason = check_outbound_host(host)
|
||||
assert ok is False, (host, reason)
|
||||
ok, reason = check_outbound_host(host, block_private=False)
|
||||
assert ok is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", [
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
"[::1]",
|
||||
"10.0.0.5",
|
||||
"192.168.1.10",
|
||||
"100.64.1.1",
|
||||
"localhost",
|
||||
"LOCALHOST.",
|
||||
"mail.localhost",
|
||||
"ip6-localhost",
|
||||
])
|
||||
def test_private_and_loopback_gated_by_block_private(host):
|
||||
ok, _ = check_outbound_host(host, block_private=True)
|
||||
assert ok is False, host
|
||||
ok, _ = check_outbound_host(host, block_private=False)
|
||||
assert ok is True, host
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", ["host:993", "http://mail.example.com", "user@mail.example.com", "mail.example.com/", "a b", "", " "])
|
||||
def test_non_bare_hosts_rejected(host):
|
||||
ok, _ = check_outbound_host(host, resolver=lambda h: ["93.184.216.34"])
|
||||
assert ok is False, host
|
||||
|
||||
|
||||
def test_nonstring_rejected():
|
||||
assert check_outbound_host(None)[0] is False # type: ignore[arg-type]
|
||||
assert check_outbound_host(123)[0] is False # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_resolved_name_is_classified():
|
||||
ok, reason = check_outbound_host("metadata.google.internal", resolver=lambda h: ["169.254.169.254"])
|
||||
assert ok is False and "link-local" in reason
|
||||
ok, _ = check_outbound_host("mail.corp", block_private=True, resolver=lambda h: ["10.1.2.3"])
|
||||
assert ok is False
|
||||
ok, _ = check_outbound_host("mail.corp", block_private=False, resolver=lambda h: ["10.1.2.3"])
|
||||
assert ok is True
|
||||
ok, _ = check_outbound_host("imap.example.org", block_private=True, resolver=lambda h: ["93.184.216.34"])
|
||||
assert ok is True
|
||||
|
||||
|
||||
def test_unresolved_policy():
|
||||
def _nx(h):
|
||||
raise OSError("Name or service not known")
|
||||
assert check_outbound_host("nope.invalid", resolver=_nx)[0] is False
|
||||
assert check_outbound_host("nope.invalid", resolver=lambda h: [])[0] is False
|
||||
|
||||
|
||||
# ── route wiring ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _route(router, path, method):
|
||||
for r in router.routes:
|
||||
if r.path == path and method in getattr(r, "methods", set()):
|
||||
return r.endpoint
|
||||
raise AssertionError(f"route not found: {method} {path}")
|
||||
|
||||
|
||||
def _make_orm_db():
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from core.database import Base
|
||||
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
||||
Base.metadata.create_all(engine)
|
||||
Factory = sessionmaker(bind=engine)
|
||||
return Factory
|
||||
|
||||
|
||||
def _set_admin(monkeypatch, is_admin: bool):
|
||||
monkeypatch.setattr("src.tool_security.owner_is_admin_or_single_user", lambda owner: is_admin)
|
||||
monkeypatch.delenv("EMAIL_BLOCK_PRIVATE_IPS", raising=False)
|
||||
monkeypatch.delenv("EMAIL_ALLOW_PRIVATE_IPS", raising=False)
|
||||
|
||||
|
||||
class _Req:
|
||||
def __init__(self, body):
|
||||
self._body = body
|
||||
|
||||
async def json(self):
|
||||
return self._body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_rejects_loopback_for_non_admin_before_any_socket(monkeypatch):
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, False)
|
||||
router = email_routes.setup_email_routes()
|
||||
test_conn = _route(router, "/api/email/accounts/test", "POST")
|
||||
|
||||
body = {
|
||||
"imap_host": "127.0.0.1", "imap_port": 11434, "imap_user": "x", "imap_password": "x",
|
||||
"imap_starttls": False,
|
||||
"smtp_host": "169.254.169.254", "smtp_port": 80, "smtp_security": "none",
|
||||
}
|
||||
with mock.patch("socket.socket") as create_socket:
|
||||
result = await test_conn(req=_Req(body), owner="regular-user")
|
||||
|
||||
assert result["ok"] is False
|
||||
assert ("Rejected IMAP host" in result["imap"]["error"] or "server address is not allowed" in result["imap"]["error"])
|
||||
assert ("Rejected SMTP host" in result["smtp"]["error"] or "server address is not allowed" in result["smtp"]["error"])
|
||||
create_socket.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_metadata_ip_rejected_even_for_admin(monkeypatch):
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, True)
|
||||
router = email_routes.setup_email_routes()
|
||||
test_conn = _route(router, "/api/email/accounts/test", "POST")
|
||||
|
||||
body = {"imap_host": "169.254.169.254", "imap_port": 80, "imap_user": "x", "imap_password": "x", "imap_starttls": True}
|
||||
with mock.patch("socket.socket") as create_socket:
|
||||
result = await test_conn(req=_Req(body), owner="admin")
|
||||
assert result["ok"] is False
|
||||
assert ("link-local" in result["imap"]["error"] or "server address is not allowed" in result["imap"]["error"])
|
||||
create_socket.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_admin_may_use_loopback(monkeypatch):
|
||||
"""Local-first: an admin's Dovecot on localhost keeps working."""
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, True)
|
||||
router = email_routes.setup_email_routes()
|
||||
test_conn = _route(router, "/api/email/accounts/test", "POST")
|
||||
|
||||
body = {"imap_host": "127.0.0.1", "imap_port": 31143, "imap_user": "x", "imap_password": "x", "imap_starttls": False}
|
||||
with mock.patch("routes.email_routes._open_imap_connection", return_value=mock.MagicMock()) as imap_open:
|
||||
result = await test_conn(req=_Req(body), owner="admin")
|
||||
assert result["imap"]["ok"] is True
|
||||
imap_open.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_account_rejects_metadata_host_before_db(monkeypatch):
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, True)
|
||||
router = email_routes.setup_email_routes()
|
||||
create = _route(router, "/api/email/accounts", "POST")
|
||||
|
||||
def _no_db():
|
||||
raise AssertionError("DB must not be touched for a rejected host")
|
||||
|
||||
with mock.patch("core.database.SessionLocal", _no_db):
|
||||
result = await create(
|
||||
{"name": "Backup", "imap_host": "169.254.169.254", "imap_user": "u", "imap_password": "p"},
|
||||
owner="admin",
|
||||
)
|
||||
assert result["ok"] is False
|
||||
assert "Rejected IMAP host" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_account_rejects_private_host_for_non_admin(monkeypatch):
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, False)
|
||||
router = email_routes.setup_email_routes()
|
||||
create = _route(router, "/api/email/accounts", "POST")
|
||||
with mock.patch("core.database.SessionLocal", _make_orm_db()):
|
||||
result = await create(
|
||||
{"name": "Scan", "imap_host": "93.184.216.34", "smtp_host": "10.0.0.5", "imap_user": "u", "imap_password": "p"},
|
||||
owner="regular-user",
|
||||
)
|
||||
assert result["ok"] is False
|
||||
assert "Rejected SMTP host" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_account_admin_private_host_allowed_unless_locked_down(monkeypatch):
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, True)
|
||||
router = email_routes.setup_email_routes()
|
||||
create = _route(router, "/api/email/accounts", "POST")
|
||||
payload = {"name": "LAN", "imap_host": "192.168.1.10", "imap_user": "u", "imap_password": "p"}
|
||||
|
||||
with mock.patch("core.database.SessionLocal", _make_orm_db()):
|
||||
result = await create(dict(payload), owner="admin")
|
||||
assert result["ok"] is True
|
||||
|
||||
monkeypatch.setenv("EMAIL_BLOCK_PRIVATE_IPS", "true")
|
||||
with mock.patch("core.database.SessionLocal", _make_orm_db()):
|
||||
result = await create(dict(payload), owner="admin")
|
||||
assert result["ok"] is False
|
||||
assert "Rejected IMAP host" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_account_rejects_bad_host_and_keeps_row(monkeypatch):
|
||||
import routes.email_routes as email_routes
|
||||
from core.database import EmailAccount
|
||||
_set_admin(monkeypatch, False)
|
||||
Factory = _make_orm_db()
|
||||
db = Factory()
|
||||
db.add(EmailAccount(id="acct-1", owner="regular-user", name="Mine", imap_host="imap.gmail.com", imap_port=993))
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
router = email_routes.setup_email_routes()
|
||||
update = _route(router, "/api/email/accounts/{account_id}", "PUT")
|
||||
with mock.patch("core.database.SessionLocal", Factory), \
|
||||
mock.patch("routes.email_routes._assert_owns_account", lambda account_id, owner: None):
|
||||
result = await update("acct-1", {"imap_host": "127.0.0.1", "imap_port": 8000}, owner="regular-user")
|
||||
assert result["ok"] is False
|
||||
assert "Rejected IMAP host" in result["error"]
|
||||
|
||||
db = Factory()
|
||||
try:
|
||||
assert db.get(EmailAccount, "acct-1").imap_host == "imap.gmail.com"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_config_rejects_bad_host(monkeypatch):
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, False)
|
||||
router = email_routes.setup_email_routes()
|
||||
update_cfg = _route(router, "/api/email/config", "PUT")
|
||||
|
||||
def _no_db():
|
||||
raise AssertionError("DB must not be touched for a rejected host")
|
||||
|
||||
with mock.patch("core.database.SessionLocal", _no_db), \
|
||||
mock.patch("routes.email_routes._load_settings", _no_db):
|
||||
result = await update_cfg({"smtp_host": "10.0.0.1", "smtp_port": 6379}, account_id=None, owner="regular-user")
|
||||
assert result["ok"] is False
|
||||
assert "Rejected SMTP host" in result["error"]
|
||||
|
||||
|
||||
# ── adversarial runtime & connection-time tests ─────────────────────────────
|
||||
|
||||
def test_connect_outbound_tcp_blocks_dns_rebinding():
|
||||
with mock.patch("socket.getaddrinfo", return_value=[(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("169.254.169.254", 993))]):
|
||||
with pytest.raises(OutboundAddressBlocked) as exc_info:
|
||||
connect_outbound_tcp("rebind.example.org", 993, block_private=False)
|
||||
assert "link-local" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_connect_outbound_tcp_multiple_ips_fails_closed():
|
||||
mixed_addrs = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 993)),
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.0.1", 993)),
|
||||
]
|
||||
with mock.patch("socket.getaddrinfo", return_value=mixed_addrs):
|
||||
with pytest.raises(OutboundAddressBlocked) as exc_info:
|
||||
connect_outbound_tcp("dual.example.org", 993, block_private=True)
|
||||
assert "private/shared/loopback" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_endpoint_dns_rebinding_blocked(monkeypatch):
|
||||
"""If DNS changes between save-check and connection, connect-time validation stops it."""
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, False)
|
||||
router = email_routes.setup_email_routes()
|
||||
test_conn = _route(router, "/api/email/accounts/test", "POST")
|
||||
|
||||
body = {
|
||||
"imap_host": "rebind.example.org", "imap_port": 993, "imap_user": "u", "imap_password": "p",
|
||||
"imap_starttls": False,
|
||||
}
|
||||
rebinding_addrs = [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 993))]
|
||||
with mock.patch("socket.getaddrinfo", return_value=rebinding_addrs), \
|
||||
mock.patch("socket.socket") as create_socket:
|
||||
result = await test_conn(req=_Req(body), owner="regular-user")
|
||||
|
||||
assert result["ok"] is False
|
||||
assert ("Rejected IMAP host" in result["imap"]["error"] or "server address is not allowed" in result["imap"]["error"])
|
||||
create_socket.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_imap_connect_blocks_preexisting_db_row(monkeypatch):
|
||||
"""A database row with private host (preexisting, restored, or DB-injected) fails closed at runtime."""
|
||||
import routes.email_helpers as email_helpers
|
||||
from core.database import EmailAccount
|
||||
_set_admin(monkeypatch, False)
|
||||
Factory = _make_orm_db()
|
||||
db = Factory()
|
||||
db.add(EmailAccount(id="acct-malicious", owner="regular-user", name="Evil", imap_host="127.0.0.1", imap_port=993, imap_user="u", imap_password="p"))
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
with mock.patch("core.database.SessionLocal", Factory):
|
||||
with pytest.raises(OutboundAddressBlocked) as exc_info:
|
||||
email_helpers._imap_connect("acct-malicious", owner="regular-user")
|
||||
assert "private/shared/loopback" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_runtime_send_smtp_message_blocks_metadata_host(monkeypatch):
|
||||
"""_send_smtp_message fails closed on cloud metadata destination."""
|
||||
import routes.email_helpers as email_helpers
|
||||
_set_admin(monkeypatch, True)
|
||||
cfg = {
|
||||
"smtp_host": "169.254.169.254",
|
||||
"smtp_port": 25,
|
||||
"owner": "admin",
|
||||
}
|
||||
with pytest.raises(OutboundAddressBlocked) as exc_info:
|
||||
email_helpers._send_smtp_message(cfg, "from@example.com", ["to@example.com"], b"data")
|
||||
assert "link-local" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_ipv6_loopback_allowed_for_admin_blocked_for_non_admin():
|
||||
mock_sock = mock.MagicMock()
|
||||
with mock.patch("socket.socket", return_value=mock_sock):
|
||||
sock = connect_outbound_tcp("::1", 993, block_private=False)
|
||||
assert sock is mock_sock
|
||||
mock_sock.connect.assert_called_with(("::1", 993, 0, 0))
|
||||
|
||||
with pytest.raises(OutboundAddressBlocked) as exc_info:
|
||||
connect_outbound_tcp("::1", 993, block_private=True)
|
||||
assert "private/shared/loopback" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_email_block_private_ips_env_overrides_admin(monkeypatch):
|
||||
import routes.email_helpers as email_helpers
|
||||
_set_admin(monkeypatch, True)
|
||||
monkeypatch.setenv("EMAIL_BLOCK_PRIVATE_IPS", "true")
|
||||
cfg = {
|
||||
"smtp_host": "127.0.0.1",
|
||||
"smtp_port": 25,
|
||||
"owner": "admin",
|
||||
}
|
||||
with pytest.raises(OutboundAddressBlocked) as exc_info:
|
||||
email_helpers._send_smtp_message(cfg, "from@example.com", ["to@example.com"], b"data")
|
||||
assert "private/shared/loopback" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_tls_sni_preserved_during_safe_connection():
|
||||
import routes.email_helpers as email_helpers
|
||||
mock_sock = mock.MagicMock()
|
||||
mock_ssl_sock = mock.MagicMock()
|
||||
mock_ctx = mock.MagicMock()
|
||||
mock_ctx.wrap_socket.return_value = mock_ssl_sock
|
||||
|
||||
with mock.patch("routes.email_helpers.connect_outbound_tcp", return_value=mock_sock):
|
||||
imap = email_helpers._PolicyIMAP4_SSL.__new__(email_helpers._PolicyIMAP4_SSL)
|
||||
imap._block_private = False
|
||||
imap.host = "imap.secure.example.com"
|
||||
imap.port = 993
|
||||
imap.ssl_context = mock_ctx
|
||||
res = imap._create_socket(10)
|
||||
mock_ctx.wrap_socket.assert_called_with(mock_sock, server_hostname="imap.secure.example.com")
|
||||
assert res is mock_ssl_sock
|
||||
|
||||
mock_smtp_sock = mock.MagicMock()
|
||||
mock_ctx.wrap_socket.return_value = mock_smtp_sock
|
||||
with mock.patch("routes.email_helpers.connect_outbound_tcp", return_value=mock_sock):
|
||||
smtp = email_helpers._PolicySMTP_SSL.__new__(email_helpers._PolicySMTP_SSL)
|
||||
smtp._block_private = False
|
||||
smtp._host = "smtp.secure.example.com"
|
||||
smtp.context = mock_ctx
|
||||
smtp.source_address = None
|
||||
res = smtp._get_socket("smtp.secure.example.com", 465, 10)
|
||||
mock_ctx.wrap_socket.assert_called_with(mock_sock, server_hostname="smtp.secure.example.com")
|
||||
assert res is mock_smtp_sock
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("route_name", ["create", "update", "config"])
|
||||
@pytest.mark.parametrize("field", ["imap_host", "smtp_host"])
|
||||
@pytest.mark.parametrize("is_admin", [False, True])
|
||||
@pytest.mark.parametrize("answer", ["error", "empty", "garbage", "none"])
|
||||
async def test_save_rejects_unresolved_hosts_before_persistence(monkeypatch, route_name, field, is_admin, answer):
|
||||
import routes.email_routes as routes
|
||||
import src.url_safety as safety
|
||||
|
||||
_set_admin(monkeypatch, is_admin)
|
||||
def resolve(host):
|
||||
if answer == "error":
|
||||
raise RuntimeError("resolver-internal-detail")
|
||||
return {"empty": [], "garbage": ["not-an-ip"], "none": None}[answer]
|
||||
|
||||
def no_persistence(*args, **kwargs):
|
||||
raise AssertionError("Rejected mail host reached persistence")
|
||||
|
||||
monkeypatch.setattr(safety, "_default_resolver", resolve)
|
||||
monkeypatch.setattr("core.database.SessionLocal", no_persistence)
|
||||
monkeypatch.setattr(routes, "_load_settings", no_persistence)
|
||||
monkeypatch.setattr(routes, "_save_settings", no_persistence)
|
||||
monkeypatch.setattr(routes, "_assert_owns_account", lambda *a: None)
|
||||
router = routes.setup_email_routes()
|
||||
payload = {"name": "Pending DNS", field: "pending.example.invalid"}
|
||||
owner = "admin" if is_admin else "regular-user"
|
||||
if route_name == "create":
|
||||
result = await _route(router, "/api/email/accounts", "POST")(payload, owner=owner)
|
||||
elif route_name == "update":
|
||||
result = await _route(router, "/api/email/accounts/{account_id}", "PUT")("acct", payload, owner=owner)
|
||||
else:
|
||||
result = await _route(router, "/api/email/config", "PUT")(payload, account_id=None, owner=owner)
|
||||
assert result["ok"] is False
|
||||
assert "does not resolve" in result["error"]
|
||||
assert "resolver-internal-detail" not in result["error"]
|
||||
assert "not-an-ip" not in result["error"]
|
||||
|
||||
|
||||
def test_connect_pins_the_first_resolution_without_rebinding_lookup():
|
||||
public = [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 993))]
|
||||
metadata = [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("169.254.169.254", 993))]
|
||||
resolver = mock.Mock(side_effect=[public, metadata])
|
||||
with mock.patch("socket.socket") as create_socket:
|
||||
sock = connect_outbound_tcp("rebind.example.org", 993, resolver=resolver, block_private=True)
|
||||
resolver.assert_called_once_with("rebind.example.org", 993, 0, socket.SOCK_STREAM)
|
||||
sock.connect.assert_called_once_with(("93.184.216.34", 993))
|
||||
|
||||
|
||||
def test_resolver_type_error_propagates_without_alternate_signature_retry():
|
||||
resolver = mock.Mock(side_effect=TypeError("resolver-internal-bug"))
|
||||
with mock.patch("socket.socket") as create_socket:
|
||||
with pytest.raises(TypeError, match="resolver-internal-bug"):
|
||||
connect_outbound_tcp("mail.example.org", 993, resolver=resolver)
|
||||
resolver.assert_called_once_with("mail.example.org", 993, 0, socket.SOCK_STREAM)
|
||||
create_socket.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("port", [None, 0, -1, 65536, True, 25.5, "not-a-port"])
|
||||
def test_invalid_tcp_port_is_rejected_before_resolution(port):
|
||||
resolver = mock.Mock()
|
||||
with pytest.raises(ValueError):
|
||||
connect_outbound_tcp("mail.example.org", port, resolver=resolver)
|
||||
resolver.assert_not_called()
|
||||
|
||||
|
||||
def test_empty_resolver_iterator_fails_closed():
|
||||
assert check_outbound_host("empty.example.org", resolver=lambda host: iter(()))[0] is False
|
||||
|
||||
|
||||
def test_shared_space_metadata_endpoint_is_identified_correctly():
|
||||
ok, reason = check_outbound_host("100.100.100.200", block_private=False)
|
||||
assert ok is False
|
||||
assert "metadata endpoint blocked" in reason
|
||||
assert "link-local" not in reason
|
||||
|
||||
|
||||
def test_importing_mail_helpers_does_not_replace_stdlib_clients():
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
subprocess.run([sys.executable, "-c", """
|
||||
import imaplib, smtplib
|
||||
original = (imaplib.IMAP4, imaplib.IMAP4_SSL, smtplib.SMTP, smtplib.SMTP_SSL)
|
||||
import routes.email.email_helpers
|
||||
assert original == (imaplib.IMAP4, imaplib.IMAP4_SSL, smtplib.SMTP, smtplib.SMTP_SSL)
|
||||
"""], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("route_name", ["create", "update", "config"])
|
||||
async def test_save_host_resolution_runs_off_the_event_loop(monkeypatch, route_name):
|
||||
import threading
|
||||
import routes.email_routes as routes
|
||||
|
||||
event_loop_thread = threading.get_ident()
|
||||
def guard(*args):
|
||||
assert threading.get_ident() != event_loop_thread
|
||||
return "Host rejected"
|
||||
|
||||
monkeypatch.setattr(routes, "_check_mail_host", guard)
|
||||
monkeypatch.setattr(routes, "_assert_owns_account", lambda *args: None)
|
||||
router = routes.setup_email_routes()
|
||||
payload = {"name": "Pending DNS", "imap_host": "pending.example.invalid"}
|
||||
if route_name == "create":
|
||||
result = await _route(router, "/api/email/accounts", "POST")(payload, owner="admin")
|
||||
elif route_name == "update":
|
||||
result = await _route(router, "/api/email/accounts/{account_id}", "PUT")("acct", payload, owner="admin")
|
||||
else:
|
||||
result = await _route(router, "/api/email/config", "PUT")(payload, account_id=None, owner="admin")
|
||||
assert result == {"ok": False, "error": "Host rejected"}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ class _FakeSock:
|
||||
class _FakeIMAP:
|
||||
calls = []
|
||||
|
||||
def __init__(self, host, port, timeout=None):
|
||||
def __init__(self, host, port, timeout=None, **_policy):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.timeout = timeout
|
||||
@@ -60,8 +60,8 @@ def test_open_imap_connection_uses_shared_timeout_for_implicit_ssl(monkeypatch):
|
||||
import routes.email_helpers as helpers
|
||||
|
||||
_FakeIMAP.calls = []
|
||||
monkeypatch.setattr(helpers.imaplib, "IMAP4", _FakeIMAP)
|
||||
monkeypatch.setattr(helpers.imaplib, "IMAP4_SSL", _FakeIMAPSSL)
|
||||
monkeypatch.setattr(helpers, "_PolicyIMAP4", _FakeIMAP)
|
||||
monkeypatch.setattr(helpers, "_PolicyIMAP4_SSL", _FakeIMAPSSL)
|
||||
|
||||
conn = _open_imap_connection("imap.one.com", 993, starttls=False)
|
||||
|
||||
@@ -75,8 +75,8 @@ def test_open_imap_connection_supports_starttls(monkeypatch):
|
||||
import routes.email_helpers as helpers
|
||||
|
||||
_FakeIMAP.calls = []
|
||||
monkeypatch.setattr(helpers.imaplib, "IMAP4", _FakeIMAP)
|
||||
monkeypatch.setattr(helpers.imaplib, "IMAP4_SSL", _FakeIMAPSSL)
|
||||
monkeypatch.setattr(helpers, "_PolicyIMAP4", _FakeIMAP)
|
||||
monkeypatch.setattr(helpers, "_PolicyIMAP4_SSL", _FakeIMAPSSL)
|
||||
|
||||
_open_imap_connection("imap.local", 143, starttls=True)
|
||||
|
||||
@@ -99,7 +99,7 @@ async def test_account_config_uses_shared_imap_timeout(monkeypatch):
|
||||
def logout(self):
|
||||
captured["logout"] = True
|
||||
|
||||
def fake_open(host, port, *, starttls, timeout):
|
||||
def fake_open(host, port, *, starttls, timeout, **_policy):
|
||||
captured["open"] = (host, port, starttls, timeout)
|
||||
return _Conn()
|
||||
|
||||
|
||||
@@ -41,18 +41,18 @@ def test_email_bulk_read_unread_checks_backend_success_before_syncing_cache():
|
||||
assert "_libCacheWriteBack()" in src
|
||||
|
||||
|
||||
def test_email_bulk_export_attachments_is_ui_only_selected_context():
|
||||
def test_email_bulk_exports_messages_and_attachments_from_selected_context():
|
||||
frontend = email_library_source()
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
export_src = frontend[
|
||||
frontend.index("async function _exportSelectedAttachments()"):
|
||||
frontend.index("\n}\n\n// URL-suffix helper", frontend.index("async function _exportSelectedAttachments()")) + 3
|
||||
]
|
||||
export_src = _function_source("_exportSelectedBundle")
|
||||
payload_src = _function_source("_selectedEmailExportPayload")
|
||||
|
||||
assert "Export Emails" in frontend
|
||||
assert "Email file (.eml)" in frontend
|
||||
assert "_exportSelectedEmailPdf" in frontend
|
||||
assert "Export Attachments" in frontend
|
||||
assert "All in view" in frontend
|
||||
assert "/api/email/attachments-download-bulk" in export_src
|
||||
assert "${isEmailExport ? 'messages' : 'attachments'}-download-bulk" in export_src
|
||||
assert "method: 'POST'" in export_src
|
||||
assert "res.blob()" in export_src
|
||||
assert "account_id: String(em.account_id || state._libAccountId || '')" in payload_src
|
||||
@@ -63,6 +63,96 @@ def test_email_bulk_export_attachments_is_ui_only_selected_context():
|
||||
assert "_fixture_email_read(uid, folder, owner)" in backend
|
||||
assert "_is_likely_signature_image_attachment(att)" in backend
|
||||
|
||||
assert '@router.post("/messages-download-bulk")' in backend
|
||||
assert '"(UID RFC822)"' in backend
|
||||
assert "msg.as_bytes(policy=email_policy.SMTP)" in backend
|
||||
assert 'media_type="message/rfc822"' in backend
|
||||
assert '"X-Exported-Emails"' in backend
|
||||
|
||||
|
||||
def test_email_print_pdf_export_reads_without_marking_read_and_sanitizes_body():
|
||||
"""Print / save PDF export must use the browser print dialog (SAN-157).
|
||||
|
||||
The removed html2pdf library must not be loaded or referenced. Instead
|
||||
the export uses a sandboxed iframe and window.print(), matching the
|
||||
established pattern in document.js.
|
||||
"""
|
||||
frontend = email_library_source()
|
||||
print_src = _function_source("_printSelectedEmails")
|
||||
menu_src = js_function_source("_showBulkActionsMenu", frontend)
|
||||
|
||||
assert "mark_seen=false&full=true" in print_src
|
||||
assert "_safeRenderEmailBody(data)" in print_src
|
||||
assert "new DOMParser()" in print_src
|
||||
assert "up to 20 emails" in print_src
|
||||
assert "Print / save PDF" in menu_src
|
||||
|
||||
# SAN-157: must NOT reference the removed html2pdf library.
|
||||
assert "html2pdf" not in print_src
|
||||
assert "html2pdf" not in frontend
|
||||
|
||||
# Must use the browser print pattern with a sandboxed iframe.
|
||||
assert "contentWindow.print()" in print_src
|
||||
assert "sandbox" in print_src
|
||||
assert "allow-same-origin" in print_src
|
||||
assert "allow-modals" in print_src
|
||||
assert "afterprint" in print_src
|
||||
assert "email-browser-print-frame" in print_src
|
||||
|
||||
|
||||
def test_email_eml_export_bounds_total_size():
|
||||
"""The /messages-download-bulk endpoint must enforce a total size cap
|
||||
to prevent unbounded memory growth when exporting many large messages."""
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
assert "max_total_bytes" in backend
|
||||
assert "total_bytes" in backend
|
||||
assert "Export too large" in backend
|
||||
assert "status_code=413" in backend
|
||||
|
||||
|
||||
def test_email_eml_export_validates_selection_count():
|
||||
"""The /messages-download-bulk endpoint must reject oversized selections."""
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
assert 'len(messages) > 250' in backend
|
||||
assert '"Too many emails selected"' in backend
|
||||
|
||||
|
||||
def test_email_eml_export_rejects_non_numeric_remote_uids():
|
||||
"""Non-numeric UIDs must not be passed to IMAP UID FETCH."""
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
# After fixture lookup fails, non-numeric UIDs are skipped.
|
||||
assert 're.fullmatch(r"\\d+", uid)' in backend
|
||||
|
||||
|
||||
def test_email_eml_export_handles_partial_batch_failures():
|
||||
"""When an IMAP fetch batch returns partial results, exported/skipped
|
||||
accounting must remain correct."""
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
# The skipped counter increments for UIDs not found in the batch response.
|
||||
assert "skipped += len(batch) - len(found)" in backend
|
||||
# Whole-connection failures count remaining unprocessed rows as skipped.
|
||||
assert "skipped += remaining_rows" in backend
|
||||
assert '"X-Exported-Emails"' in backend
|
||||
assert '"X-Skipped-Emails"' in backend
|
||||
|
||||
|
||||
def test_email_eml_export_enforces_account_ownership():
|
||||
"""The /messages-download-bulk endpoint must assert account ownership."""
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
assert "_assert_owns_account(account_id, owner)" in backend
|
||||
|
||||
|
||||
def test_email_eml_export_deduplicates_selection():
|
||||
"""Duplicate items in selection must be skipped to avoid duplicate files."""
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
assert "seen_items" in backend
|
||||
|
||||
|
||||
def test_fixture_tag_filter_supports_receipt_tag_flow():
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
@@ -171,3 +261,808 @@ def test_email_compose_can_attach_gallery_images():
|
||||
assert "GalleryImage" in backend
|
||||
assert "_gallery_image_path(img.filename)" in backend
|
||||
assert "_stage_compose_file" in backend
|
||||
|
||||
|
||||
class _MockImapConn:
|
||||
def __init__(self, messages_by_uid: dict[str, bytes] | None = None, fetch_exc: Exception | None = None, fetch_cb=None):
|
||||
self.messages = messages_by_uid or {}
|
||||
self.fetch_exc = fetch_exc
|
||||
self.fetch_cb = fetch_cb
|
||||
self.fetch_calls: list = []
|
||||
|
||||
def select(self, folder, readonly=True):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def uid(self, command, *args):
|
||||
if command == "FETCH":
|
||||
self.fetch_calls.append(args)
|
||||
if self.fetch_exc is not None:
|
||||
raise self.fetch_exc
|
||||
if self.fetch_cb:
|
||||
return self.fetch_cb(args)
|
||||
uid_set_b = args[0]
|
||||
uid_set = uid_set_b.decode() if isinstance(uid_set_b, bytes) else str(uid_set_b)
|
||||
uids = [u.strip() for u in uid_set.split(",") if u.strip()]
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
res = []
|
||||
if "RFC822.SIZE" in fetch_cmd:
|
||||
for u in uids:
|
||||
if u in self.messages:
|
||||
meta = f"1 (UID {u} RFC822.SIZE {len(self.messages[u])})".encode()
|
||||
res.append(meta)
|
||||
return "OK", res
|
||||
for u in uids:
|
||||
if u in self.messages:
|
||||
meta = f"1 (UID {u} RFC822 {{{len(self.messages[u])}}}".encode()
|
||||
res.append((meta, self.messages[u]))
|
||||
res.append(b")")
|
||||
return "OK", res
|
||||
return "NO", []
|
||||
|
||||
|
||||
def _create_test_app(monkeypatch, mailbox: _MockImapConn | None = None, assert_owns_fn=None):
|
||||
from contextlib import contextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.email import email_routes as routes
|
||||
|
||||
conn = mailbox or _MockImapConn()
|
||||
|
||||
@contextmanager
|
||||
def mock_imap(account_id=None, owner=""):
|
||||
yield conn
|
||||
|
||||
monkeypatch.setattr(routes, "_start_poller", lambda: None)
|
||||
monkeypatch.setattr(routes, "_POOL_HOOKS", {})
|
||||
monkeypatch.setattr(routes, "_imap", mock_imap)
|
||||
if assert_owns_fn:
|
||||
monkeypatch.setattr(routes, "_assert_owns_account", assert_owns_fn)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(routes.setup_email_routes())
|
||||
app.dependency_overrides[routes.require_owner] = lambda: "test-user"
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_messages_download_bulk_validates_empty_and_oversized_payload(monkeypatch):
|
||||
client = _create_test_app(monkeypatch)
|
||||
|
||||
res_empty = client.post("/api/email/messages-download-bulk", json={"messages": []})
|
||||
assert res_empty.status_code == 400
|
||||
assert "No emails selected" in res_empty.text
|
||||
|
||||
oversized = [{"uid": str(i)} for i in range(251)]
|
||||
res_over = client.post("/api/email/messages-download-bulk", json={"messages": oversized})
|
||||
assert res_over.status_code == 400
|
||||
assert "Too many emails selected" in res_over.text
|
||||
|
||||
|
||||
def test_messages_download_bulk_single_message_eml_export(monkeypatch):
|
||||
raw_rfc822 = b"From: sender@example.com\r\nTo: test-user\r\nSubject: Report\r\n\r\nReport content"
|
||||
mailbox = _MockImapConn({"101": raw_rfc822})
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101", "subject": "Quarterly Report", "date": "2026-10-20"}]
|
||||
})
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.headers["content-type"].startswith("message/rfc822")
|
||||
assert res.headers["x-exported-emails"] == "1"
|
||||
assert res.headers["x-skipped-emails"] == "0"
|
||||
assert "email.eml" in res.headers["content-disposition"]
|
||||
assert res.content == raw_rfc822
|
||||
|
||||
|
||||
def test_messages_download_bulk_multi_message_zip_export(monkeypatch):
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
msg1 = b"From: a@example.com\r\nSubject: First\r\n\r\nOne"
|
||||
msg2 = b"From: b@example.com\r\nSubject: Second\r\n\r\nTwo"
|
||||
mailbox = _MockImapConn({"101": msg1, "102": msg2})
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [
|
||||
{"uid": "101", "subject": "First Msg", "date": "2026-10-18"},
|
||||
{"uid": "102", "subject": "Second Msg", "date": "2026-10-19"},
|
||||
]
|
||||
})
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.headers["content-type"] == "application/zip"
|
||||
assert res.headers["x-exported-emails"] == "2"
|
||||
assert res.headers["x-skipped-emails"] == "0"
|
||||
|
||||
zf = zipfile.ZipFile(io.BytesIO(res.content))
|
||||
namelist = zf.namelist()
|
||||
assert len(namelist) == 2
|
||||
assert any("101.eml" in name for name in namelist)
|
||||
assert any("102.eml" in name for name in namelist)
|
||||
|
||||
|
||||
def test_messages_download_bulk_partial_failure_and_duplicate_dedup(monkeypatch):
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
msg1 = b"From: a@example.com\r\nSubject: Exists\r\n\r\nExists body"
|
||||
# UID 101 exists, UID 999 does not exist in mailbox
|
||||
mailbox = _MockImapConn({"101": msg1})
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [
|
||||
{"uid": "101", "subject": "Exists"},
|
||||
{"uid": "999", "subject": "Missing"},
|
||||
{"uid": "101", "subject": "Duplicate"}, # duplicate must be skipped
|
||||
]
|
||||
})
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.headers["content-type"] == "application/zip"
|
||||
assert res.headers["x-exported-emails"] == "1"
|
||||
assert res.headers["x-skipped-emails"] == "2"
|
||||
|
||||
zf = zipfile.ZipFile(io.BytesIO(res.content))
|
||||
assert len(zf.namelist()) == 1
|
||||
|
||||
|
||||
def test_messages_download_bulk_enforces_account_ownership_guard(monkeypatch):
|
||||
from fastapi import HTTPException
|
||||
|
||||
def mock_assert_owns(account_id, owner):
|
||||
if account_id == "other-tenant-acct":
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
|
||||
client = _create_test_app(monkeypatch, assert_owns_fn=mock_assert_owns)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101", "account_id": "other-tenant-acct"}]
|
||||
})
|
||||
|
||||
assert res.status_code == 404
|
||||
assert "Account not found" in res.text
|
||||
|
||||
|
||||
def test_messages_download_bulk_mid_batch_failure_accounting(monkeypatch):
|
||||
"""When an exception occurs mid-batch after partial progress, already-exported
|
||||
records must not be double-counted as skipped."""
|
||||
class MidBatchFailImap:
|
||||
def select(self, folder, readonly=True):
|
||||
return "OK", [b"1"]
|
||||
def uid(self, command, *args):
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
if "RFC822.SIZE" in fetch_cmd:
|
||||
return "OK", [f"{i} (UID {i} RFC822.SIZE 100)".encode() for i in range(1, 11)]
|
||||
records = []
|
||||
for i in range(1, 5):
|
||||
records.append((f"{i} (UID {i} RFC822 {{{10}}}".encode(), f"Subject: Msg {i}\r\n\r\nBody {i}".encode()))
|
||||
class BadRecord:
|
||||
def __bytes__(self):
|
||||
raise RuntimeError("Corrupt memory buffer")
|
||||
records.append((b"5 (UID 5 RFC822 {10}", BadRecord()))
|
||||
return "OK", records
|
||||
|
||||
from contextlib import contextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.email import email_routes as routes
|
||||
|
||||
@contextmanager
|
||||
def mock_imap(account_id=None, owner=""):
|
||||
yield MidBatchFailImap()
|
||||
|
||||
monkeypatch.setattr(routes, "_start_poller", lambda: None)
|
||||
monkeypatch.setattr(routes, "_POOL_HOOKS", {})
|
||||
monkeypatch.setattr(routes, "_imap", mock_imap)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(routes.setup_email_routes())
|
||||
app.dependency_overrides[routes.require_owner] = lambda: "test-user"
|
||||
client = TestClient(app)
|
||||
|
||||
rows = [{"uid": str(i)} for i in range(1, 11)]
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": rows})
|
||||
assert res.status_code == 200
|
||||
exp = int(res.headers.get("x-exported-emails"))
|
||||
skp = int(res.headers.get("x-skipped-emails"))
|
||||
assert exp == 4
|
||||
assert skp == 6
|
||||
assert exp + skp == 10
|
||||
|
||||
|
||||
def test_messages_download_bulk_size_cap_413_boundary(monkeypatch):
|
||||
"""Cumulative export size exceeding max_total_bytes returns HTTP 413."""
|
||||
class LargeMessageImap:
|
||||
def select(self, folder, readonly=True):
|
||||
return "OK", [b"1"]
|
||||
def uid(self, command, *args):
|
||||
raw_uids = args[0] if isinstance(args[0], str) else args[0].decode()
|
||||
if "(UID RFC822.SIZE)" in str(args):
|
||||
records = []
|
||||
for u in raw_uids.split(","):
|
||||
records.append(f"{u} (UID {u} RFC822.SIZE 10485760)".encode())
|
||||
return "OK", records
|
||||
records = []
|
||||
big_chunk = b"X" * (10 * 1024 * 1024)
|
||||
for u in raw_uids.split(","):
|
||||
records.append((f"{u} (UID {u} RFC822".encode(), big_chunk))
|
||||
return "OK", records
|
||||
|
||||
from contextlib import contextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.email import email_routes as routes
|
||||
|
||||
@contextmanager
|
||||
def mock_imap(account_id=None, owner=""):
|
||||
yield LargeMessageImap()
|
||||
|
||||
monkeypatch.setattr(routes, "_start_poller", lambda: None)
|
||||
monkeypatch.setattr(routes, "_POOL_HOOKS", {})
|
||||
monkeypatch.setattr(routes, "_imap", mock_imap)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(routes.setup_email_routes())
|
||||
app.dependency_overrides[routes.require_owner] = lambda: "test-user"
|
||||
client = TestClient(app)
|
||||
|
||||
# 52 messages of 10 MB = 520 MB > 512 MiB limit
|
||||
payload_52 = [{"uid": str(i + 1), "subject": f"Big {i}"} for i in range(52)]
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": payload_52})
|
||||
assert res.status_code == 413
|
||||
assert res.json()["detail"] == "Export too large"
|
||||
|
||||
|
||||
def test_messages_download_bulk_per_message_limit_413(monkeypatch):
|
||||
"""A single oversized message (>64 MiB) triggers HTTP 413."""
|
||||
class HugeSingleMsgImap:
|
||||
def select(self, folder, readonly=True):
|
||||
return "OK", [b"1"]
|
||||
def uid(self, command, *args):
|
||||
return "OK", [b"1 (UID 101 RFC822.SIZE 73400320)"]
|
||||
|
||||
from contextlib import contextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.email import email_routes as routes
|
||||
|
||||
@contextmanager
|
||||
def mock_imap(account_id=None, owner=""):
|
||||
yield HugeSingleMsgImap()
|
||||
|
||||
monkeypatch.setattr(routes, "_start_poller", lambda: None)
|
||||
monkeypatch.setattr(routes, "_POOL_HOOKS", {})
|
||||
monkeypatch.setattr(routes, "_imap", mock_imap)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(routes.setup_email_routes())
|
||||
app.dependency_overrides[routes.require_owner] = lambda: "test-user"
|
||||
client = TestClient(app)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101", "subject": "Huge Email"}]
|
||||
})
|
||||
assert res.status_code == 413
|
||||
assert res.json()["detail"] == "Export too large"
|
||||
|
||||
|
||||
def test_messages_download_bulk_account_ownership_memoization(monkeypatch):
|
||||
"""Account ownership validation is memoized per distinct account_id to eliminate N+1 DB queries."""
|
||||
owns_calls = []
|
||||
def counting_assert_owns(account_id, owner):
|
||||
owns_calls.append(account_id)
|
||||
|
||||
raw_rfc822 = b"From: sender@example.com\r\nSubject: Test\r\n\r\nBody"
|
||||
msgs = {str(i): raw_rfc822 for i in range(1, 26)}
|
||||
mailbox = _MockImapConn(msgs)
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox, assert_owns_fn=counting_assert_owns)
|
||||
|
||||
# 25 messages all referencing the same account_id
|
||||
payload = [{"uid": str(i), "account_id": "acc-shared"} for i in range(1, 26)]
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": payload})
|
||||
assert res.status_code == 200
|
||||
assert len(owns_calls) == 1
|
||||
|
||||
|
||||
def test_messages_download_bulk_corrupt_zip_fails_closed(monkeypatch):
|
||||
"""If ZIP construction fails (e.g. disk error), fail closed with 500 instead of returning corrupt archive."""
|
||||
import zipfile
|
||||
raw_rfc822 = b"From: sender@example.com\r\nSubject: Test\r\n\r\nBody"
|
||||
mailbox = _MockImapConn({"101": raw_rfc822, "102": raw_rfc822})
|
||||
|
||||
orig_writestr = zipfile.ZipFile.writestr
|
||||
def failing_writestr(self, zinfo_or_arcname, data=None, *args, **kwargs):
|
||||
raise OSError("Disk write failed: No space left on device")
|
||||
|
||||
monkeypatch.setattr(zipfile.ZipFile, "writestr", failing_writestr)
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101"}, {"uid": "102"}]
|
||||
})
|
||||
assert res.status_code == 500
|
||||
assert "Failed to create export archive" in res.json()["detail"]
|
||||
|
||||
|
||||
def test_messages_download_bulk_preflight_no_fails_safe_no_body_fetch(monkeypatch):
|
||||
"""When preflight returns NO, size safety is unknown: fail safely (502) and do not fetch bodies."""
|
||||
mailbox = _MockImapConn({"101": b"dummy", "102": b"dummy"})
|
||||
mailbox.fetch_cb = lambda args: ("NO", []) if "RFC822.SIZE" in str(args) else ("OK", [])
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}, {"uid": "102"}]})
|
||||
assert res.status_code == 502
|
||||
assert "Unable to verify email size safety" in res.json()["detail"]
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
|
||||
def test_messages_download_bulk_preflight_empty_missing_mail(monkeypatch):
|
||||
"""When preflight returns empty data, all requested messages are missing on server: returns 404 with no body fetch."""
|
||||
mailbox = _MockImapConn() # no messages
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}, {"uid": "102"}]})
|
||||
assert res.status_code == 404
|
||||
assert "No selected emails could be exported" in res.json()["detail"]
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
|
||||
def test_messages_download_bulk_preflight_malformed_fails_safe_no_body_fetch(monkeypatch):
|
||||
"""When preflight returns records without RFC822.SIZE, fail safely (502) and do not fetch bodies."""
|
||||
mailbox = _MockImapConn({"101": b"dummy"})
|
||||
mailbox.fetch_cb = lambda args: ("OK", [b"1 (UID 101 FLAGS (\\Seen))"]) if "RFC822.SIZE" in str(args) else ("OK", [])
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
|
||||
assert res.status_code == 502
|
||||
assert "Invalid email size metadata from server" in res.json()["detail"]
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
|
||||
def test_messages_download_bulk_preflight_inconsistent_fails_safe_no_body_fetch(monkeypatch):
|
||||
"""When preflight returns negative or corrupted size values, fail safely (502) and do not fetch bodies."""
|
||||
mailbox = _MockImapConn({"101": b"dummy"})
|
||||
mailbox.fetch_cb = lambda args: ("OK", [b"1 (UID 101 RFC822.SIZE -50)"]) if "RFC822.SIZE" in str(args) else ("OK", [])
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
|
||||
assert res.status_code == 502
|
||||
assert "Invalid email size metadata from server" in res.json()["detail"]
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
|
||||
def test_messages_download_bulk_preflight_partial_missing_omitted_from_body_fetch(monkeypatch):
|
||||
"""When some requested UIDs do not exist on the server, omit them from body fetch and export verified items."""
|
||||
msg1 = b"From: a@example.com\r\nSubject: Only One\r\n\r\nBody"
|
||||
mailbox = _MockImapConn({"101": msg1}) # 102 does not exist on server
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101"}, {"uid": "102"}]
|
||||
})
|
||||
assert res.status_code == 200
|
||||
assert res.headers["x-exported-emails"] == "1"
|
||||
assert res.headers["x-skipped-emails"] == "1"
|
||||
|
||||
# Verify body fetch was only requested for 101, NOT 102
|
||||
body_fetches = [call for call in mailbox.fetch_calls if "(UID RFC822)" in str(call)]
|
||||
assert len(body_fetches) == 1
|
||||
requested_uids = body_fetches[0][0].decode() if isinstance(body_fetches[0][0], bytes) else str(body_fetches[0][0])
|
||||
assert requested_uids == "101"
|
||||
assert "102" not in requested_uids
|
||||
|
||||
|
||||
def test_messages_download_bulk_duplicate_imap_records_rejected(monkeypatch):
|
||||
"""Duplicate IMAP FETCH response records for the same UID must be ignored.
|
||||
They must not produce duplicate archive entries or corrupt accounting."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
msg1 = b"From: a@example.com\r\nSubject: Unique\r\n\r\nBody 1"
|
||||
mailbox = _MockImapConn({"101": msg1})
|
||||
|
||||
def custom_fetch(args):
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
if "RFC822.SIZE" in fetch_cmd:
|
||||
return "OK", [b"1 (UID 101 RFC822.SIZE 100)", b"2 (UID 102 RFC822.SIZE 100)"]
|
||||
# In body fetch, server sends duplicate records for 101, and nothing for 102
|
||||
records = [
|
||||
(b"1 (UID 101 RFC822 {50}", msg1),
|
||||
b")",
|
||||
(b"1 (UID 101 RFC822 {50}", msg1), # DUPLICATE RECORD
|
||||
b")",
|
||||
]
|
||||
return "OK", records
|
||||
|
||||
mailbox.fetch_cb = custom_fetch
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101"}, {"uid": "102"}]
|
||||
})
|
||||
assert res.status_code == 200
|
||||
assert res.headers["x-exported-emails"] == "1"
|
||||
assert res.headers["x-skipped-emails"] == "1"
|
||||
|
||||
zf = zipfile.ZipFile(io.BytesIO(res.content))
|
||||
assert len(zf.namelist()) == 1
|
||||
assert "101.eml" in zf.namelist()[0]
|
||||
|
||||
|
||||
def test_messages_download_bulk_single_message_preflight_failure_no_body_fetch(monkeypatch):
|
||||
"""In single message mode, bad preflight or missing mail fails safely without body fetch."""
|
||||
mailbox = _MockImapConn({"101": b"dummy"})
|
||||
|
||||
# Case A: preflight NO -> 502
|
||||
mailbox.fetch_cb = lambda args: ("NO", []) if "RFC822.SIZE" in str(args) else ("OK", [])
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
res_no = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
|
||||
assert res_no.status_code == 502
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
# Case B: missing mail -> 404
|
||||
mailbox.fetch_calls.clear()
|
||||
mailbox.fetch_cb = lambda args: ("OK", []) if "RFC822.SIZE" in str(args) else ("OK", [])
|
||||
res_miss = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
|
||||
assert res_miss.status_code == 404
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
# Case C: malformed metadata -> 502
|
||||
mailbox.fetch_calls.clear()
|
||||
mailbox.fetch_cb = lambda args: ("OK", [b"1 (UID 101 NO_SIZE)"]) if "RFC822.SIZE" in str(args) else ("OK", [])
|
||||
res_mal = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
|
||||
assert res_mal.status_code == 502
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
|
||||
def test_messages_download_bulk_spooled_stream_lifecycle():
|
||||
"""Verify that _iter_spooled closes the underlying SpooledTemporaryFile on completion and GeneratorExit."""
|
||||
import tempfile
|
||||
from routes.email.email_routes import setup_email_routes
|
||||
|
||||
# Test normal completion
|
||||
f = tempfile.SpooledTemporaryFile(max_size=1024, mode="w+b")
|
||||
f.write(b"Hello world payload")
|
||||
f.seek(0)
|
||||
# Re-create generator helper pattern
|
||||
def _iter(file_obj, chunk_size=64):
|
||||
try:
|
||||
while True:
|
||||
chunk = file_obj.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
file_obj.close()
|
||||
|
||||
gen = _iter(f)
|
||||
chunks = list(gen)
|
||||
assert b"".join(chunks) == b"Hello world payload"
|
||||
assert f.closed
|
||||
|
||||
# Test early disconnect / cancellation (GeneratorExit)
|
||||
f2 = tempfile.SpooledTemporaryFile(max_size=1024, mode="w+b")
|
||||
f2.write(b"Chunk1Chunk2Chunk3")
|
||||
f2.seek(0)
|
||||
gen2 = _iter(f2, chunk_size=6)
|
||||
next(gen2)
|
||||
assert not f2.closed
|
||||
gen2.close() # Simulates client disconnect / generator cancellation
|
||||
assert f2.closed
|
||||
|
||||
|
||||
def test_uid_and_size_from_fetch_meta_case_insensitivity_and_validation():
|
||||
"""Unit test: _uid_from_fetch_meta and _size_from_fetch_meta case insensitivity and boundary validation."""
|
||||
from routes.email.email_routes import _uid_from_fetch_meta, _size_from_fetch_meta
|
||||
|
||||
# 1. Standard uppercase UID and RFC822.SIZE
|
||||
meta_upper = b"1 (UID 101 RFC822.SIZE 1234)"
|
||||
assert _uid_from_fetch_meta(meta_upper) == "101"
|
||||
assert _size_from_fetch_meta(meta_upper) == 1234
|
||||
|
||||
# 2. Lowercase uid and rfc822.size
|
||||
meta_lower = b"1 (uid 202 rfc822.size 5678)"
|
||||
assert _uid_from_fetch_meta(meta_lower) == "202"
|
||||
assert _size_from_fetch_meta(meta_lower) == 5678
|
||||
|
||||
# 3. Mixed-case combinations
|
||||
combos = [
|
||||
(b"1 (Uid 303 Rfc822.Size 1000)", "303", 1000),
|
||||
(b"1 (uID 404 RFC822.size 2000)", "404", 2000),
|
||||
(b"1 (UID 505 rfc822.size 3000)", "505", 3000),
|
||||
(b"1 (uid 606 RFC822.SIZE 4000)", "606", 4000),
|
||||
(b"1 (uId 707 Rfc822.SIZE 5000)", "707", 5000),
|
||||
(b"1 (FLAGS (\\Seen) uid 808 rfc822.Size 6000)", "808", 6000),
|
||||
]
|
||||
for meta_bytes, exp_uid, exp_sz in combos:
|
||||
assert _uid_from_fetch_meta(meta_bytes) == exp_uid
|
||||
assert _size_from_fetch_meta(meta_bytes) == exp_sz
|
||||
|
||||
# 4. Malformed metadata cases
|
||||
assert _uid_from_fetch_meta(b"1 (rfc822.size 1234)") == ""
|
||||
assert _size_from_fetch_meta(b"1 (uid 101 flags (\\seen))") is None
|
||||
assert _uid_from_fetch_meta(b"1 (uid abc rfc822.size 1234)") == ""
|
||||
assert _size_from_fetch_meta(b"1 (uid 101 rfc822.size invalid)") is None
|
||||
assert _size_from_fetch_meta(b"1 (uid 101 rfc822.size -50)") is None
|
||||
|
||||
# 5. Boundary integrity (must not match substrings like UUID, GUID, NOT_RFC822.SIZE)
|
||||
assert _uid_from_fetch_meta(b"1 (UUID 101 GUID 202 NOT_RFC822.SIZE 1234)") == ""
|
||||
assert _size_from_fetch_meta(b"1 (UUID 101 GUID 202 NOT_RFC822.SIZE 1234)") is None
|
||||
assert _uid_from_fetch_meta(b"1 (uid101 rfc822.size1234)") == ""
|
||||
assert _size_from_fetch_meta(b"1 (uid101 rfc822.size1234)") is None
|
||||
assert _uid_from_fetch_meta(b"") == ""
|
||||
assert _size_from_fetch_meta(b"") is None
|
||||
|
||||
|
||||
def test_messages_download_bulk_case_insensitive_imap_atoms(monkeypatch):
|
||||
"""End-to-end: Bulk and single-message export with lowercase and mixed-case IMAP atoms."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
msg101 = b"From: a@example.com\r\nSubject: Item 101\r\n\r\nBody 1"
|
||||
msg102 = b"From: b@example.com\r\nSubject: Item 102\r\n\r\nBody 2"
|
||||
mailbox = _MockImapConn({"101": msg101, "102": msg102})
|
||||
|
||||
def custom_fetch(args):
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
if "RFC822.SIZE" in fetch_cmd or "rfc822.size" in fetch_cmd.lower():
|
||||
# Server returns lowercase/mixed-case atoms
|
||||
return "OK", [
|
||||
b"1 (uid 101 rfc822.size " + str(len(msg101)).encode() + b")",
|
||||
b"2 (Uid 102 Rfc822.Size " + str(len(msg102)).encode() + b")",
|
||||
]
|
||||
# In body fetch, server returns mixed-case atoms
|
||||
return "OK", [
|
||||
(b"1 (uid 101 rfc822 {" + str(len(msg101)).encode(), msg101),
|
||||
b")",
|
||||
(b"2 (Uid 102 RFC822 {" + str(len(msg102)).encode(), msg102),
|
||||
b")",
|
||||
]
|
||||
|
||||
mailbox.fetch_cb = custom_fetch
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
# 1. Bulk download (2 items) with lowercase / mixed-case IMAP responses
|
||||
res_bulk = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101"}, {"uid": "102"}]
|
||||
})
|
||||
assert res_bulk.status_code == 200
|
||||
assert res_bulk.headers["x-exported-emails"] == "2"
|
||||
assert res_bulk.headers["x-skipped-emails"] == "0"
|
||||
zf = zipfile.ZipFile(io.BytesIO(res_bulk.content))
|
||||
assert len(zf.namelist()) == 2
|
||||
|
||||
# 2. Single message download fast path with lowercase IMAP response
|
||||
mailbox.fetch_calls.clear()
|
||||
res_single = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101"}]
|
||||
})
|
||||
assert res_single.status_code == 200
|
||||
assert res_single.headers["content-type"].startswith("message/rfc822")
|
||||
assert res_single.content == msg101
|
||||
|
||||
# 3. Lowercase malformed preflight fails safely with 502 and NO body fetch
|
||||
mailbox.fetch_calls.clear()
|
||||
mailbox.fetch_cb = lambda args: (
|
||||
("OK", [b"1 (uid 101 rfc822.size NaN)"])
|
||||
if "RFC822.SIZE" in str(args) or "rfc822.size" in str(args).lower()
|
||||
else ("OK", [])
|
||||
)
|
||||
res_malformed = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101"}]
|
||||
})
|
||||
assert res_malformed.status_code == 502
|
||||
assert "Invalid email size metadata from server" in res_malformed.json()["detail"]
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
|
||||
def test_messages_download_bulk_multi_group_preflight_failure_propagates_502(monkeypatch):
|
||||
"""A preflight safety failure in any group produces HTTP 502 for the entire request,
|
||||
even when another group has already exported messages into the temporary ZIP.
|
||||
Confirms temporary-file cleanup, zero body FETCH for the failed group, and both group orderings."""
|
||||
import tempfile
|
||||
|
||||
msg101 = b"From: a@example.com\r\nSubject: Good\r\n\r\nBody 1"
|
||||
|
||||
spooled_instances: list[tempfile.SpooledTemporaryFile] = []
|
||||
orig_spooled = tempfile.SpooledTemporaryFile
|
||||
|
||||
def tracking_spooled(*args, **kwargs):
|
||||
inst = orig_spooled(*args, **kwargs)
|
||||
spooled_instances.append(inst)
|
||||
return inst
|
||||
|
||||
monkeypatch.setattr(tempfile, "SpooledTemporaryFile", tracking_spooled)
|
||||
|
||||
class MultiMailboxConn:
|
||||
def __init__(self):
|
||||
self.fetch_calls: list[tuple[str, tuple]] = []
|
||||
self.current_folder = "INBOX"
|
||||
|
||||
def select(self, folder, readonly=True):
|
||||
self.current_folder = str(folder)
|
||||
return "OK", [b"1"]
|
||||
|
||||
def uid(self, command, *args):
|
||||
self.fetch_calls.append((self.current_folder, args))
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
uid_set = args[0].decode() if isinstance(args[0], bytes) else str(args[0])
|
||||
|
||||
if "RFC822.SIZE" in fetch_cmd:
|
||||
if "folder-fail" in self.current_folder:
|
||||
return "NO", [] # Preflight failure for folder-fail
|
||||
return "OK", [f"1 (UID {uid_set} RFC822.SIZE 100)".encode()]
|
||||
|
||||
# Body fetch
|
||||
if "folder-fail" in self.current_folder:
|
||||
return "NO", []
|
||||
return "OK", [(f"1 (UID {uid_set} RFC822 {{100}}".encode(), msg101), b")"]
|
||||
|
||||
# 1. Successful group followed by failed preflight
|
||||
conn1 = MultiMailboxConn()
|
||||
client1 = _create_test_app(monkeypatch, mailbox=conn1)
|
||||
spooled_instances.clear()
|
||||
|
||||
res1 = client1.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [
|
||||
{"uid": "101", "folder": "folder-good"},
|
||||
{"uid": "202", "folder": "folder-fail"},
|
||||
]
|
||||
})
|
||||
assert res1.status_code == 502
|
||||
assert "Unable to verify email size safety" in res1.json()["detail"]
|
||||
|
||||
# No RFC822 body FETCH for the failed group
|
||||
failed_body_fetches1 = [
|
||||
call for folder, call in conn1.fetch_calls
|
||||
if "folder-fail" in folder and "(UID RFC822)" in str(call)
|
||||
]
|
||||
assert len(failed_body_fetches1) == 0
|
||||
|
||||
# Temporary file cleanup confirmed
|
||||
assert len(spooled_instances) == 1
|
||||
assert spooled_instances[0].closed
|
||||
|
||||
# 2. Failed preflight followed by successful group
|
||||
conn2 = MultiMailboxConn()
|
||||
client2 = _create_test_app(monkeypatch, mailbox=conn2)
|
||||
spooled_instances.clear()
|
||||
|
||||
res2 = client2.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [
|
||||
{"uid": "202", "folder": "folder-fail"},
|
||||
{"uid": "101", "folder": "folder-good"},
|
||||
]
|
||||
})
|
||||
assert res2.status_code == 502
|
||||
assert "Unable to verify email size safety" in res2.json()["detail"]
|
||||
|
||||
# No RFC822 body FETCH for the failed group
|
||||
failed_body_fetches2 = [
|
||||
call for folder, call in conn2.fetch_calls
|
||||
if "folder-fail" in folder and "(UID RFC822)" in str(call)
|
||||
]
|
||||
assert len(failed_body_fetches2) == 0
|
||||
|
||||
# Temporary file cleanup confirmed
|
||||
assert len(spooled_instances) == 1
|
||||
assert spooled_instances[0].closed
|
||||
|
||||
|
||||
def test_messages_download_bulk_multi_group_both_successful(monkeypatch):
|
||||
"""When both groups succeed preflight and body fetch, return 200 with all messages in ZIP."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
msg101 = b"From: a@example.com\r\nSubject: Item 1\r\n\r\nBody 1"
|
||||
msg202 = b"From: b@example.com\r\nSubject: Item 2\r\n\r\nBody 2"
|
||||
|
||||
class MultiMailboxConn:
|
||||
def __init__(self):
|
||||
self.current_folder = "INBOX"
|
||||
|
||||
def select(self, folder, readonly=True):
|
||||
self.current_folder = str(folder)
|
||||
return "OK", [b"1"]
|
||||
|
||||
def uid(self, command, *args):
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
uid_set = args[0].decode() if isinstance(args[0], bytes) else str(args[0])
|
||||
msg = msg101 if "101" in uid_set else msg202
|
||||
if "RFC822.SIZE" in fetch_cmd:
|
||||
return "OK", [f"1 (UID {uid_set} RFC822.SIZE {len(msg)})".encode()]
|
||||
return "OK", [(f"1 (UID {uid_set} RFC822 {{{len(msg)}}}".encode(), msg), b")"]
|
||||
|
||||
conn = MultiMailboxConn()
|
||||
client = _create_test_app(monkeypatch, mailbox=conn)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [
|
||||
{"uid": "101", "folder": "folder-1"},
|
||||
{"uid": "202", "folder": "folder-2"},
|
||||
]
|
||||
})
|
||||
assert res.status_code == 200
|
||||
assert res.headers["x-exported-emails"] == "2"
|
||||
assert res.headers["x-skipped-emails"] == "0"
|
||||
zf = zipfile.ZipFile(io.BytesIO(res.content))
|
||||
assert len(zf.namelist()) == 2
|
||||
|
||||
|
||||
def test_messages_download_bulk_multi_group_genuinely_missing_message(monkeypatch):
|
||||
"""Genuinely missing messages (unreturned by preflight) do not cause 502;
|
||||
they are skipped while verified messages in any group are successfully exported."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
msg101 = b"From: a@example.com\r\nSubject: Exists\r\n\r\nBody"
|
||||
|
||||
class MissingMailboxConn:
|
||||
def __init__(self):
|
||||
self.fetch_calls: list = []
|
||||
self.current_folder = "INBOX"
|
||||
|
||||
def select(self, folder, readonly=True):
|
||||
self.current_folder = str(folder)
|
||||
return "OK", [b"1"]
|
||||
|
||||
def uid(self, command, *args):
|
||||
self.fetch_calls.append((self.current_folder, args))
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
uid_set = args[0].decode() if isinstance(args[0], bytes) else str(args[0])
|
||||
if "RFC822.SIZE" in fetch_cmd:
|
||||
# 101 exists on server; 999 is genuinely missing (unreported in size metadata)
|
||||
res = []
|
||||
if "101" in uid_set:
|
||||
res.append(f"1 (UID 101 RFC822.SIZE {len(msg101)})".encode())
|
||||
return "OK", res
|
||||
# Body fetch only returns 101
|
||||
return "OK", [(f"1 (UID 101 RFC822 {{{len(msg101)}}}".encode(), msg101), b")"]
|
||||
|
||||
conn = MissingMailboxConn()
|
||||
client = _create_test_app(monkeypatch, mailbox=conn)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [
|
||||
{"uid": "101", "folder": "folder-1"},
|
||||
{"uid": "999", "folder": "folder-1"},
|
||||
]
|
||||
})
|
||||
assert res.status_code == 200
|
||||
assert res.headers["x-exported-emails"] == "1"
|
||||
assert res.headers["x-skipped-emails"] == "1"
|
||||
|
||||
zf = zipfile.ZipFile(io.BytesIO(res.content))
|
||||
assert len(zf.namelist()) == 1
|
||||
|
||||
# Verify 999 was never requested in body fetch
|
||||
body_fetches = [
|
||||
call for folder, call in conn.fetch_calls
|
||||
if "(UID RFC822)" in str(call)
|
||||
]
|
||||
assert len(body_fetches) == 1
|
||||
assert "999" not in str(body_fetches[0])
|
||||
|
||||
|
||||
def test_email_print_browser_behaviors():
|
||||
"""Real Chromium DOM: placeholder stripping, button removal, and partial failure."""
|
||||
import shutil
|
||||
import subprocess
|
||||
if not shutil.which('node'):
|
||||
import pytest
|
||||
pytest.skip('Node is required')
|
||||
probe = subprocess.run(['node', '-e', "require.resolve('playwright')"], cwd=_REPO, capture_output=True)
|
||||
if probe.returncode:
|
||||
import pytest
|
||||
pytest.skip('Playwright is required')
|
||||
result = subprocess.run(
|
||||
['node', str(_REPO / 'tests/test_email_library_print_browser.cjs')],
|
||||
cwd=_REPO,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert "ALL_BROWSER_TESTS_PASSED" in result.stdout
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* Browser print regression tests for Email Library print and PDF export.
|
||||
*
|
||||
* Requirements:
|
||||
* - Node.js (v20+)
|
||||
* - Playwright (`npm ci` or `npm install -D @playwright/test`)
|
||||
* - Chromium browser binary:
|
||||
* - Standard Playwright install: `npx playwright install chromium`
|
||||
* - Or custom executable via env vars:
|
||||
* - `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/path/to/chrome`
|
||||
* - `CHROME_PATH=/path/to/chrome`
|
||||
* - Or custom browsers cache dir via:
|
||||
* - `PLAYWRIGHT_BROWSERS_PATH=/path/to/cache`
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
let chromium;
|
||||
try {
|
||||
({ chromium } = require('playwright'));
|
||||
} catch (err) {
|
||||
if (err && err.code === 'MODULE_NOT_FOUND') {
|
||||
console.error(
|
||||
'Error: Playwright module not found.\n' +
|
||||
'Please install dependencies by running:\n' +
|
||||
' npm ci\n' +
|
||||
'or ensure NODE_PATH contains the playwright package.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
// Discover available Chromium executable using standard Playwright resolution,
|
||||
// custom environment variables, standard cache paths, or system binaries.
|
||||
function findChromiumPath() {
|
||||
// 1. Explicit environment variable overrides
|
||||
if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH && fs.existsSync(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH)) {
|
||||
return process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
|
||||
}
|
||||
if (process.env.CHROME_PATH && fs.existsSync(process.env.CHROME_PATH)) {
|
||||
return process.env.CHROME_PATH;
|
||||
}
|
||||
|
||||
// 2. Playwright's configured/default executable path
|
||||
try {
|
||||
const defaultPath = chromium.executablePath();
|
||||
if (defaultPath && fs.existsSync(defaultPath)) {
|
||||
return defaultPath;
|
||||
}
|
||||
} catch (e) {
|
||||
// chromium.executablePath() may throw if no default browser is installed
|
||||
}
|
||||
|
||||
// 3. Inspect standard Playwright browser cache directories
|
||||
const cacheDirs = [];
|
||||
if (process.env.PLAYWRIGHT_BROWSERS_PATH) {
|
||||
cacheDirs.push(process.env.PLAYWRIGHT_BROWSERS_PATH);
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
if (process.env.LOCALAPPDATA) {
|
||||
cacheDirs.push(path.join(process.env.LOCALAPPDATA, 'ms-playwright'));
|
||||
}
|
||||
cacheDirs.push(path.join(os.homedir(), 'AppData', 'Local', 'ms-playwright'));
|
||||
} else if (process.platform === 'darwin') {
|
||||
cacheDirs.push(path.join(os.homedir(), 'Library', 'Caches', 'ms-playwright'));
|
||||
} else {
|
||||
cacheDirs.push(path.join(os.homedir(), '.cache', 'ms-playwright'));
|
||||
}
|
||||
|
||||
for (const cacheDir of cacheDirs) {
|
||||
if (!fs.existsSync(cacheDir)) continue;
|
||||
try {
|
||||
const entries = fs.readdirSync(cacheDir).sort().reverse();
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(cacheDir, entry);
|
||||
const candidates = [
|
||||
path.join(entryPath, 'chrome-headless-shell-linux64', 'chrome-headless-shell'),
|
||||
path.join(entryPath, 'chrome-linux64', 'chrome'),
|
||||
path.join(entryPath, 'chrome-linux', 'chrome'),
|
||||
path.join(entryPath, 'chrome-win64', 'chrome.exe'),
|
||||
path.join(entryPath, 'chrome-win', 'chrome.exe'),
|
||||
path.join(entryPath, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// 4. System browser fallbacks
|
||||
const systemCandidates = process.platform === 'win32' ? [
|
||||
path.join(process.env['PROGRAMFILES'] || 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
||||
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
||||
] : [
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/snap/bin/chromium',
|
||||
];
|
||||
for (const sc of systemCandidates) {
|
||||
if (fs.existsSync(sc)) return sc;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url, 'http://127.0.0.1');
|
||||
if (url.pathname.startsWith('/api/email/read/')) {
|
||||
const uid = url.pathname.replace('/api/email/read/', '');
|
||||
if (uid === 'fail_uid' || uid === 'net_fail') {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Mailbox disconnected' }));
|
||||
return;
|
||||
}
|
||||
if (uid === 'crash_network') {
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const markSeen = url.searchParams.get('mark_seen');
|
||||
if (markSeen !== 'false') {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'mark_seen must be false' }));
|
||||
return;
|
||||
}
|
||||
|
||||
let body_html = '';
|
||||
if (uid === 'img_test') {
|
||||
body_html = [
|
||||
'<div>',
|
||||
'<p>Email with diverse images</p>',
|
||||
'<!-- Inline image with alt text -->',
|
||||
'<img src="cid:logo123" alt="Company Brand Logo">',
|
||||
'<!-- Remote image with title -->',
|
||||
'<img src="https://example.com/tracker.png" title="Tracking Pixel">',
|
||||
'<!-- Image without alt or title -->',
|
||||
'<img src="https://example.com/no-alt.jpg">',
|
||||
'<!-- Interactive control that shouldn\'t be in print -->',
|
||||
'<button type="button" class="btn-action">Interactive Action</button>',
|
||||
'<!-- XSS payload -->',
|
||||
'<script>window.__pwned = true;</script>',
|
||||
'</div>'
|
||||
].join('\n');
|
||||
} else if (uid === 'long_msg') {
|
||||
body_html = `<div><p>${'A'.repeat(5000)}</p></div>`;
|
||||
} else {
|
||||
body_html = `<div><p>Standard message body for ${uid}</p></div>`;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
uid: uid,
|
||||
subject: `Subject ${uid}`,
|
||||
from_name: 'Sender',
|
||||
from_address: 'sender@example.com',
|
||||
to: 'user@example.com',
|
||||
date: '2026-10-20',
|
||||
date_display: 'Oct 20, 2026',
|
||||
body: `Plain text for ${uid}`,
|
||||
body_html: body_html,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
let filePath = path.join(ROOT, url.pathname);
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||
const ext = path.extname(filePath);
|
||||
const mime = ext === '.js' ? 'application/javascript' : ext === '.html' ? 'text/html' : ext === '.css' ? 'text/css' : 'text/plain';
|
||||
res.writeHead(200, { 'Content-Type': mime });
|
||||
res.end(fs.readFileSync(filePath));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
});
|
||||
|
||||
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
||||
const port = server.address().port;
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
|
||||
const launchOpts = { headless: true };
|
||||
const chromePath = findChromiumPath();
|
||||
if (chromePath) launchOpts.executablePath = chromePath;
|
||||
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch(launchOpts);
|
||||
} catch (launchErr) {
|
||||
throw new Error(
|
||||
`Failed to launch Chromium browser: ${launchErr.message}\n` +
|
||||
'Missing or unexecutable Chromium binary.\n' +
|
||||
'To install Playwright Chromium, run:\n' +
|
||||
' npx playwright install chromium\n' +
|
||||
'Or set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH to a valid Chromium executable.'
|
||||
);
|
||||
}
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
const results = {};
|
||||
|
||||
try {
|
||||
await page.goto(`${origin}/static/index.html`);
|
||||
await page.evaluate(() => {
|
||||
if (!document.getElementById('email-lib-bulk-actions')) {
|
||||
const d = document.createElement('div'); d.id = 'email-lib-bulk-actions'; document.body.appendChild(d);
|
||||
}
|
||||
if (!document.getElementById('email-lib-selected-count')) {
|
||||
const d = document.createElement('div'); d.id = 'email-lib-selected-count'; document.body.appendChild(d);
|
||||
}
|
||||
});
|
||||
|
||||
// TEST 1: Full Print Image Sanitization and Replacement
|
||||
const printImageResult = await page.evaluate(async (base) => {
|
||||
window.API_BASE = base;
|
||||
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
|
||||
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
|
||||
|
||||
stateMod.state._selectedUids = new Set(['img_test']);
|
||||
stateMod.state._libEmails = [{ uid: 'img_test', subject: 'Image Test', folder: 'INBOX' }];
|
||||
|
||||
let iframePrintInvoked = false;
|
||||
let printDocHtml = '';
|
||||
let afterPrintTriggered = false;
|
||||
let hasPlaceholderElement = false;
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const frame = document.getElementById('email-browser-print-frame');
|
||||
if (frame && frame.contentWindow && !frame._patched) {
|
||||
frame._patched = true;
|
||||
frame.contentWindow.print = function() {
|
||||
iframePrintInvoked = true;
|
||||
printDocHtml = frame.contentDocument ? frame.contentDocument.documentElement.outerHTML : '';
|
||||
const body = frame.contentDocument ? frame.contentDocument.body : null;
|
||||
hasPlaceholderElement = !!body?.querySelector('.email-inline-image-placeholder');
|
||||
frame.contentWindow.dispatchEvent(new Event('afterprint'));
|
||||
afterPrintTriggered = true;
|
||||
};
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true });
|
||||
|
||||
await mod._exportSelectedEmailPdf();
|
||||
observer.disconnect();
|
||||
|
||||
const frameAfter = document.getElementById('email-browser-print-frame');
|
||||
|
||||
return {
|
||||
iframePrintInvoked,
|
||||
afterPrintTriggered,
|
||||
frameRemovedAfterPrint: frameAfter === null,
|
||||
hasLogoNotice: printDocHtml.includes('[Image: Company Brand Logo]'),
|
||||
hasTrackerNotice: printDocHtml.includes('[Image: Tracking Pixel]'),
|
||||
hasNoAltNotice: printDocHtml.includes('[Image omitted]'),
|
||||
hasPlaceholderElement,
|
||||
hasRemoteImageBlockedText: printDocHtml.includes('Remote image blocked'),
|
||||
hasDownloadButton: printDocHtml.includes('email-inline-image-download-btn'),
|
||||
hasInteractiveActionBtn: printDocHtml.includes('btn-action'),
|
||||
hasXSS: printDocHtml.includes('<script>') || printDocHtml.includes('window.__pwned'),
|
||||
};
|
||||
}, origin);
|
||||
|
||||
results.printImageSanitization = printImageResult;
|
||||
|
||||
// TEST 2: Partial Failure - 1 failure out of 3 emails
|
||||
const partialFailureResult = await page.evaluate(async (base) => {
|
||||
window.API_BASE = base;
|
||||
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
|
||||
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
|
||||
|
||||
const messages = [
|
||||
{ uid: 'msg1', subject: 'Email One', folder: 'INBOX' },
|
||||
{ uid: 'fail_uid', subject: 'Failing Email', folder: 'INBOX' },
|
||||
{ uid: 'msg2', subject: 'Email Two', folder: 'INBOX' },
|
||||
];
|
||||
stateMod.state._selectedUids = new Set(messages.map(m => m.uid));
|
||||
stateMod.state._libEmails = messages;
|
||||
|
||||
let printInvoked = false;
|
||||
let printDocHtml = '';
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const frame = document.getElementById('email-browser-print-frame');
|
||||
if (frame && frame.contentWindow && !frame._patched) {
|
||||
frame._patched = true;
|
||||
frame.contentWindow.print = function() {
|
||||
printInvoked = true;
|
||||
printDocHtml = frame.contentDocument ? frame.contentDocument.documentElement.outerHTML : '';
|
||||
frame.contentWindow.dispatchEvent(new Event('afterprint'));
|
||||
};
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true });
|
||||
|
||||
await mod._exportSelectedEmailPdf();
|
||||
observer.disconnect();
|
||||
|
||||
const toast = document.getElementById('toast')?.textContent || '';
|
||||
|
||||
return {
|
||||
printInvoked,
|
||||
toast,
|
||||
hasMsg1: printDocHtml.includes('Subject msg1'),
|
||||
hasMsg2: printDocHtml.includes('Subject msg2'),
|
||||
hasOmittedNotice: printDocHtml.includes('1 selected email was unavailable and omitted from this print'),
|
||||
};
|
||||
}, origin);
|
||||
|
||||
results.partialFailureTest = partialFailureResult;
|
||||
|
||||
// TEST 3: All emails fail
|
||||
const allFailedResult = await page.evaluate(async (base) => {
|
||||
window.API_BASE = base;
|
||||
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
|
||||
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
|
||||
|
||||
const messages = [
|
||||
{ uid: 'fail_uid', subject: 'Fail 1', folder: 'INBOX' },
|
||||
{ uid: 'net_fail', subject: 'Fail 2', folder: 'INBOX' },
|
||||
];
|
||||
stateMod.state._selectedUids = new Set(messages.map(m => m.uid));
|
||||
stateMod.state._libEmails = messages;
|
||||
|
||||
let printInvoked = false;
|
||||
const observer = new MutationObserver(() => {
|
||||
const frame = document.getElementById('email-browser-print-frame');
|
||||
if (frame && frame.contentWindow && !frame._patched) {
|
||||
frame._patched = true;
|
||||
frame.contentWindow.print = function() {
|
||||
printInvoked = true;
|
||||
};
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true });
|
||||
|
||||
let thrownError = null;
|
||||
try {
|
||||
await mod._exportSelectedEmailPdf();
|
||||
} catch (err) {
|
||||
thrownError = err.message || String(err);
|
||||
}
|
||||
observer.disconnect();
|
||||
|
||||
const frame = document.getElementById('email-browser-print-frame');
|
||||
const toast = document.getElementById('toast')?.textContent || '';
|
||||
|
||||
return {
|
||||
printInvoked,
|
||||
frameOpened: frame !== null,
|
||||
thrownError,
|
||||
toast,
|
||||
};
|
||||
}, origin);
|
||||
|
||||
results.allFailedTest = allFailedResult;
|
||||
|
||||
// TEST 4: Idempotent Iframe Cleanup & Window Afterprint
|
||||
const cleanupResult = await page.evaluate(async (base) => {
|
||||
window.API_BASE = base;
|
||||
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
|
||||
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
|
||||
|
||||
stateMod.state._selectedUids = new Set(['msg1']);
|
||||
stateMod.state._libEmails = [{ uid: 'msg1', subject: 'Cleanup Test', folder: 'INBOX' }];
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const frame = document.getElementById('email-browser-print-frame');
|
||||
if (frame && frame.contentWindow && !frame._patched) {
|
||||
frame._patched = true;
|
||||
frame.contentWindow.print = function() {
|
||||
window.dispatchEvent(new Event('afterprint'));
|
||||
};
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true });
|
||||
|
||||
await mod._exportSelectedEmailPdf();
|
||||
observer.disconnect();
|
||||
|
||||
const frameAfter = document.getElementById('email-browser-print-frame');
|
||||
return {
|
||||
frameRemovedViaWindowAfterprint: frameAfter === null,
|
||||
};
|
||||
}, origin);
|
||||
|
||||
results.cleanupTest = cleanupResult;
|
||||
|
||||
} finally {
|
||||
await browser.close();
|
||||
server.close();
|
||||
}
|
||||
|
||||
// Assertions
|
||||
const t1 = results.printImageSanitization;
|
||||
if (!t1.iframePrintInvoked) throw new Error('Assertion failed: print not invoked');
|
||||
if (!t1.frameRemovedAfterPrint) throw new Error('Assertion failed: frame not removed');
|
||||
if (!t1.hasLogoNotice) throw new Error('Assertion failed: [Image: Company Brand Logo] missing');
|
||||
if (!t1.hasTrackerNotice) throw new Error('Assertion failed: [Image: Tracking Pixel] missing');
|
||||
if (!t1.hasNoAltNotice) throw new Error('Assertion failed: [Image omitted] missing');
|
||||
if (t1.hasPlaceholderElement) throw new Error('Assertion failed: .email-inline-image-placeholder found in print doc');
|
||||
if (t1.hasRemoteImageBlockedText) throw new Error('Assertion failed: Remote image blocked leaked into print doc');
|
||||
if (t1.hasDownloadButton) throw new Error('Assertion failed: download button leaked into print doc');
|
||||
if (t1.hasInteractiveActionBtn) throw new Error('Assertion failed: interactive button leaked into print doc');
|
||||
if (t1.hasXSS) throw new Error('Assertion failed: script tag found');
|
||||
|
||||
const t2 = results.partialFailureTest;
|
||||
if (!t2.printInvoked) throw new Error('Assertion failed: partial print was not invoked');
|
||||
if (!t2.hasMsg1 || !t2.hasMsg2) throw new Error('Assertion failed: successful messages missing from print');
|
||||
if (!t2.hasOmittedNotice) throw new Error('Assertion failed: omitted notice banner missing');
|
||||
if (!t2.toast.includes('Prepared 2 emails for printing; 1 unavailable')) {
|
||||
throw new Error('Assertion failed: toast text mismatch: ' + t2.toast);
|
||||
}
|
||||
|
||||
const t3 = results.allFailedTest;
|
||||
if (t3.printInvoked) throw new Error('Assertion failed: print should NOT be invoked when all fail');
|
||||
if (t3.frameOpened) throw new Error('Assertion failed: print frame should NOT remain in DOM');
|
||||
|
||||
const t4 = results.cleanupTest;
|
||||
if (!t4.frameRemovedViaWindowAfterprint) throw new Error('Assertion failed: top window afterprint did not clean up frame');
|
||||
|
||||
console.log('ALL_BROWSER_TESTS_PASSED');
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
console.error('Browser test failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -64,7 +64,7 @@ def test_imap_connect_with_host_still_connects(monkeypatch):
|
||||
def login(self, user, password):
|
||||
opened["login"] = (user, password)
|
||||
|
||||
def _fake_open(host, port, *, starttls, timeout):
|
||||
def _fake_open(host, port, *, starttls, timeout, **_policy):
|
||||
opened["host"] = host
|
||||
return _FakeConn()
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from routes.email_helpers import _send_smtp_message
|
||||
class _FakeSMTP:
|
||||
calls = []
|
||||
|
||||
def __init__(self, host, port, timeout=None):
|
||||
def __init__(self, host, port, timeout=None, **_policy):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.timeout = timeout
|
||||
@@ -54,8 +54,8 @@ def test_send_smtp_message_supports_plain_smtp(monkeypatch):
|
||||
import routes.email_helpers as helpers
|
||||
|
||||
_FakeSMTP.calls = []
|
||||
monkeypatch.setattr(helpers.smtplib, "SMTP", _FakeSMTP)
|
||||
monkeypatch.setattr(helpers.smtplib, "SMTP_SSL", _FakeSMTPSSL)
|
||||
monkeypatch.setattr(helpers, "_PolicySMTP", _FakeSMTP)
|
||||
monkeypatch.setattr(helpers, "_PolicySMTP_SSL", _FakeSMTPSSL)
|
||||
|
||||
_send_smtp_message(_cfg("none"), "from@example.com", ["to@example.com"], "hello")
|
||||
|
||||
@@ -68,8 +68,8 @@ def test_send_smtp_message_supports_explicit_starttls(monkeypatch):
|
||||
import routes.email_helpers as helpers
|
||||
|
||||
_FakeSMTP.calls = []
|
||||
monkeypatch.setattr(helpers.smtplib, "SMTP", _FakeSMTP)
|
||||
monkeypatch.setattr(helpers.smtplib, "SMTP_SSL", _FakeSMTPSSL)
|
||||
monkeypatch.setattr(helpers, "_PolicySMTP", _FakeSMTP)
|
||||
monkeypatch.setattr(helpers, "_PolicySMTP_SSL", _FakeSMTPSSL)
|
||||
|
||||
_send_smtp_message(_cfg("starttls", port=2525), "from@example.com", ["to@example.com"], "hello")
|
||||
|
||||
@@ -82,8 +82,8 @@ def test_send_smtp_message_defaults_587_to_starttls(monkeypatch):
|
||||
import routes.email_helpers as helpers
|
||||
|
||||
_FakeSMTP.calls = []
|
||||
monkeypatch.setattr(helpers.smtplib, "SMTP", _FakeSMTP)
|
||||
monkeypatch.setattr(helpers.smtplib, "SMTP_SSL", _FakeSMTPSSL)
|
||||
monkeypatch.setattr(helpers, "_PolicySMTP", _FakeSMTP)
|
||||
monkeypatch.setattr(helpers, "_PolicySMTP_SSL", _FakeSMTPSSL)
|
||||
|
||||
cfg = _cfg("", port=587)
|
||||
_send_smtp_message(cfg, "from@example.com", ["to@example.com"], "hello")
|
||||
@@ -96,8 +96,8 @@ def test_send_smtp_message_uses_ssl_when_configured(monkeypatch):
|
||||
import routes.email_helpers as helpers
|
||||
|
||||
_FakeSMTP.calls = []
|
||||
monkeypatch.setattr(helpers.smtplib, "SMTP", _FakeSMTP)
|
||||
monkeypatch.setattr(helpers.smtplib, "SMTP_SSL", _FakeSMTPSSL)
|
||||
monkeypatch.setattr(helpers, "_PolicySMTP", _FakeSMTP)
|
||||
monkeypatch.setattr(helpers, "_PolicySMTP_SSL", _FakeSMTPSSL)
|
||||
|
||||
_send_smtp_message(_cfg("ssl", port=465), "from@example.com", ["to@example.com"], "hello")
|
||||
|
||||
|
||||
@@ -89,8 +89,8 @@ async def test_test_connection_oauth_account_uses_xoauth2_for_imap_and_smtp():
|
||||
|
||||
with mock.patch("core.database.SessionLocal", Factory), \
|
||||
mock.patch("routes.email_routes._open_imap_connection", return_value=mock_imap_conn), \
|
||||
mock.patch("routes.email_routes.smtplib.SMTP", return_value=mock_smtp_conn), \
|
||||
mock.patch("routes.email_routes.smtplib.SMTP_SSL", return_value=mock_smtp_conn), \
|
||||
mock.patch("routes.email_routes._PolicySMTP", return_value=mock_smtp_conn), \
|
||||
mock.patch("routes.email_routes._PolicySMTP_SSL", return_value=mock_smtp_conn), \
|
||||
mock.patch("routes.email_routes._get_valid_google_token", return_value="ya29.live") as token_getter:
|
||||
result = await test_conn(req=_FakeReq(), owner="alice")
|
||||
|
||||
@@ -141,8 +141,8 @@ async def test_test_connection_password_account_still_uses_login():
|
||||
|
||||
with mock.patch("core.database.SessionLocal", Factory), \
|
||||
mock.patch("routes.email_routes._open_imap_connection", return_value=mock_imap_conn), \
|
||||
mock.patch("routes.email_routes.smtplib.SMTP", return_value=mock_smtp_conn), \
|
||||
mock.patch("routes.email_routes.smtplib.SMTP_SSL", return_value=mock_smtp_conn):
|
||||
mock.patch("routes.email_routes._PolicySMTP", return_value=mock_smtp_conn), \
|
||||
mock.patch("routes.email_routes._PolicySMTP_SSL", return_value=mock_smtp_conn):
|
||||
result = await test_conn(req=_FakeReq(), owner="alice")
|
||||
|
||||
assert result["ok"] is True
|
||||
@@ -187,8 +187,8 @@ async def test_test_connection_rejects_non_google_hosts_before_oauth_auth():
|
||||
|
||||
with mock.patch("core.database.SessionLocal", Factory), \
|
||||
mock.patch("routes.email_routes._open_imap_connection") as open_imap, \
|
||||
mock.patch("routes.email_routes.smtplib.SMTP") as open_smtp, \
|
||||
mock.patch("routes.email_routes.smtplib.SMTP_SSL") as open_smtp_ssl, \
|
||||
mock.patch("routes.email_routes._PolicySMTP") as open_smtp, \
|
||||
mock.patch("routes.email_routes._PolicySMTP_SSL") as open_smtp_ssl, \
|
||||
mock.patch("routes.email_routes._get_valid_google_token") as token_getter:
|
||||
result = await test_conn(req=_FakeReq(), owner="alice")
|
||||
|
||||
@@ -238,8 +238,8 @@ async def test_test_connection_rejects_insecure_oauth_transports_before_auth():
|
||||
|
||||
with mock.patch("core.database.SessionLocal", Factory), \
|
||||
mock.patch("routes.email_routes._open_imap_connection") as open_imap, \
|
||||
mock.patch("routes.email_routes.smtplib.SMTP") as open_smtp, \
|
||||
mock.patch("routes.email_routes.smtplib.SMTP_SSL") as open_smtp_ssl, \
|
||||
mock.patch("routes.email_routes._PolicySMTP") as open_smtp, \
|
||||
mock.patch("routes.email_routes._PolicySMTP_SSL") as open_smtp_ssl, \
|
||||
mock.patch("routes.email_routes._get_valid_google_token") as token_getter:
|
||||
result = await test_conn(req=_FakeReq(), owner="alice")
|
||||
|
||||
@@ -336,10 +336,10 @@ async def test_test_connection_verifies_imap_tls_before_loading_oauth_token(
|
||||
"routes.email_routes.ssl.create_default_context",
|
||||
return_value=context,
|
||||
), mock.patch(
|
||||
"routes.email_helpers.imaplib.IMAP4",
|
||||
"routes.email_helpers._PolicyIMAP4",
|
||||
return_value=starttls_conn,
|
||||
) as imap_cls, mock.patch(
|
||||
"routes.email_helpers.imaplib.IMAP4_SSL",
|
||||
"routes.email_helpers._PolicyIMAP4_SSL",
|
||||
side_effect=ssl.SSLCertVerificationError("untrusted certificate"),
|
||||
) as imap_ssl_cls, mock.patch(
|
||||
"routes.email_routes._get_valid_google_token"
|
||||
@@ -412,10 +412,10 @@ async def test_test_connection_verifies_smtp_tls_before_loading_oauth_token(
|
||||
"routes.email_routes.ssl.create_default_context",
|
||||
return_value=context,
|
||||
), mock.patch(
|
||||
"routes.email_routes.smtplib.SMTP",
|
||||
"routes.email_routes._PolicySMTP",
|
||||
return_value=starttls_smtp,
|
||||
) as smtp_cls, mock.patch(
|
||||
"routes.email_routes.smtplib.SMTP_SSL",
|
||||
"routes.email_routes._PolicySMTP_SSL",
|
||||
side_effect=ssl.SSLCertVerificationError("untrusted certificate"),
|
||||
) as smtp_ssl_cls, mock.patch(
|
||||
"routes.email_routes._get_valid_google_token"
|
||||
|
||||
@@ -79,3 +79,68 @@ async def test_inherited_authority_still_cannot_widen_to_ungranted_bash(tmp_path
|
||||
assert pending.process_operation is None
|
||||
_, result = await _dispatch(child, "bash", "printf approved-run", exact)
|
||||
assert result.get("blocked") is True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent_workspace():
|
||||
import os
|
||||
from src.constants import AGENT_WORKSPACE_DIR
|
||||
os.makedirs(AGENT_WORKSPACE_DIR, exist_ok=True)
|
||||
return os.path.realpath(AGENT_WORKSPACE_DIR)
|
||||
|
||||
|
||||
def test_approval_without_a_workspace_seals_a_launch_in_the_agent_workspace(agent_workspace):
|
||||
# Agent mode with no folder picked is the default. bash already runs in the
|
||||
# agent workspace there (agent_cwd), so that is where its approval seals.
|
||||
authority = _authority("", "get_workspace")
|
||||
|
||||
pending, _ = _approve(authority, "bash", "pwd")
|
||||
|
||||
bound = pending.process_operation
|
||||
assert bound is not None
|
||||
assert bound.launch is not None
|
||||
assert bound.launch.scope.root.path == agent_workspace
|
||||
|
||||
|
||||
async def test_approved_bash_without_a_workspace_runs_in_the_agent_workspace(agent_workspace):
|
||||
authority = _authority("", "get_workspace")
|
||||
_, exact = _approve(authority, "bash", "pwd")
|
||||
|
||||
description, result = await _dispatch(authority, "bash", "pwd", exact)
|
||||
|
||||
assert result.get("failure_kind") != "resource_identity_denied", result
|
||||
assert result.get("exit_code") == 0, result
|
||||
containment = result.get("containment") or {}
|
||||
assert containment.get("workspace", agent_workspace) == agent_workspace, containment
|
||||
|
||||
|
||||
async def test_inherited_authority_without_a_workspace_still_cannot_widen(agent_workspace):
|
||||
from dataclasses import replace
|
||||
child = replace(_authority("", "get_workspace"), inherited=True)
|
||||
pending, exact = _approve(child, "bash", "pwd")
|
||||
|
||||
assert pending.process_operation is None
|
||||
_, result = await _dispatch(child, "bash", "pwd", exact)
|
||||
assert result.get("blocked") is True
|
||||
|
||||
|
||||
async def test_approved_write_without_a_workspace_lands_in_the_agent_workspace(agent_workspace):
|
||||
# write_file had the same gap: with no workspace the approval sealed no
|
||||
# root, so "approve" ended in "no sealed resource identity".
|
||||
import os
|
||||
target = "approved-write-6635.txt"
|
||||
content = f"{target}\nhello"
|
||||
authority = _authority("", "get_workspace")
|
||||
pending, exact = _approve(authority, "write_file", content)
|
||||
assert pending.resource_operation is not None
|
||||
|
||||
_, result = await _dispatch(authority, "write_file", content, exact)
|
||||
|
||||
assert result.get("failure_kind") != "resource_identity_denied", result
|
||||
written = os.path.join(agent_workspace, target)
|
||||
try:
|
||||
with open(written) as fh:
|
||||
assert fh.read().strip() == "hello"
|
||||
finally:
|
||||
if os.path.exists(written):
|
||||
os.remove(written)
|
||||
|
||||
@@ -25,3 +25,15 @@ def test_image_model_picker_lives_in_the_settings_container():
|
||||
def test_picker_persists_with_the_shared_localstorage_key():
|
||||
assert "localStorage.setItem(_GE_MODEL_KEY" in GALLERY_JS
|
||||
assert "localStorage.getItem(_GE_MODEL_KEY)" in GALLERY_JS
|
||||
|
||||
|
||||
def test_cloud_image_models_are_listed_alongside_the_local_ones():
|
||||
"""Gateway endpoints (OpenRouter) register as llm but carry image models.
|
||||
|
||||
The picker must surface those ids too — otherwise the only selectable
|
||||
edit model is the local Qwen server.
|
||||
"""
|
||||
routes = (ROOT / "routes/editor_agent_routes.py").read_text(encoding="utf-8")
|
||||
assert "from src.image_model_ids import looks_like_image_generation_model" in routes
|
||||
assert 'image_endpoint = getattr(ep, "model_type", None) == "image"' in routes
|
||||
assert "if not image_endpoint and not looks_like_image_generation_model(name):" in routes
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import pytest
|
||||
|
||||
from services.hwfit.fit import rank_models
|
||||
from services.hwfit.models import get_models, is_prequantized
|
||||
from tests.hwfit_publication_fixtures import publication_catalog # noqa: F401
|
||||
|
||||
# Rank authored inputs rather than publication catalog snapshots.
|
||||
pytestmark = pytest.mark.usefixtures("publication_catalog")
|
||||
|
||||
|
||||
def _8gb_vram_system():
|
||||
@@ -14,38 +20,63 @@ def _8gb_vram_system():
|
||||
}
|
||||
|
||||
|
||||
def test_gemma4_12b_in_catalog():
|
||||
catalog = {m["name"]: m for m in get_models()}
|
||||
assert "google/gemma-4-12B-it" in catalog, "gemma-4-12B-it missing from catalog"
|
||||
GEMMA = "google/gemma-4-12B-it"
|
||||
|
||||
|
||||
def test_gemma4_12b_has_gguf_source():
|
||||
catalog = {m["name"]: m for m in get_models()}
|
||||
entry = catalog["google/gemma-4-12B-it"]
|
||||
assert entry.get("gguf_sources"), "gemma-4-12B-it has no gguf_sources"
|
||||
repos = [s["repo"] for s in entry["gguf_sources"]]
|
||||
assert "unsloth/gemma-4-12B-it-GGUF" in repos
|
||||
def _input_row(rows, name):
|
||||
return next(r for r in rows if r["name"] == name)
|
||||
|
||||
|
||||
def _qat_rows(rows):
|
||||
return [r for r in rows if r["name"].startswith(GEMMA + "-qat-")]
|
||||
|
||||
|
||||
def test_gemma4_12b_user_catalog_row_wins_the_merge(publication_catalog, monkeypatch):
|
||||
"""A dynamic cache listing the same repo must not replace or duplicate the
|
||||
user-catalog row; rows only the cache knows are still merged in."""
|
||||
from services.hwfit import hf_discovery
|
||||
|
||||
monkeypatch.setattr(hf_discovery, "load_cached_hf_collection_models", lambda: [
|
||||
{"name": GEMMA, "quantization": "F16", "gguf_sources": [{"repo": "test/cache-GGUF", "file": "cache.gguf"}]},
|
||||
{"name": "test/cache-only", "quantization": "F16", "gguf_sources": []},
|
||||
])
|
||||
merged = get_models()
|
||||
names = [m["name"] for m in merged]
|
||||
|
||||
assert names.count(GEMMA) == 1
|
||||
assert "test/cache-only" in names
|
||||
entry = next(m for m in merged if m["name"] == GEMMA)
|
||||
assert entry["gguf_sources"] == _input_row(publication_catalog, GEMMA)["gguf_sources"]
|
||||
|
||||
|
||||
def test_gemma4_12b_ranked_row_carries_its_gguf_source(publication_catalog):
|
||||
hit = next(r for r in rank_models(_8gb_vram_system(), search="gemma-4-12B-it", limit=20) if r["name"] == GEMMA)
|
||||
assert hit["gguf_sources"] == _input_row(publication_catalog, GEMMA)["gguf_sources"]
|
||||
# The F16 input does not fit 8 GB, so the fit falls back to a GGUF quant.
|
||||
assert hit["quant"] == "Q4_K_M"
|
||||
|
||||
|
||||
def test_gemma4_12b_rank_models_returns_it_for_8gb_vram():
|
||||
results = rank_models(_8gb_vram_system(), search="gemma-4-12B-it", limit=20)
|
||||
names = [r["name"] for r in results]
|
||||
assert "google/gemma-4-12B-it" in names, "rank_models did not return gemma-4-12B-it for 8 GB VRAM"
|
||||
assert GEMMA in names, "rank_models did not return gemma-4-12B-it for 8 GB VRAM"
|
||||
|
||||
|
||||
def test_gemma4_12b_qat_entries_in_catalog():
|
||||
def test_gemma4_12b_qat_entries_rank_with_their_native_quant(publication_catalog):
|
||||
qat = _qat_rows(publication_catalog)
|
||||
assert len(qat) == 2
|
||||
ranked = {r["name"]: r for r in rank_models(_8gb_vram_system(), search="gemma-4-12B-it-qat", limit=20)}
|
||||
for row in qat:
|
||||
assert ranked[row["name"]]["quant"] == row["quantization"]
|
||||
|
||||
|
||||
def test_gemma4_12b_qat_entries_are_prequantized(publication_catalog):
|
||||
catalog = {m["name"]: m for m in get_models()}
|
||||
assert "google/gemma-4-12B-it-qat-int4" in catalog
|
||||
assert "google/gemma-4-12B-it-qat-int8" in catalog
|
||||
for row in _qat_rows(publication_catalog):
|
||||
assert is_prequantized(catalog[row["name"]])
|
||||
|
||||
|
||||
def test_gemma4_12b_qat_entries_are_prequantized():
|
||||
catalog = {m["name"]: m for m in get_models()}
|
||||
assert is_prequantized(catalog["google/gemma-4-12B-it-qat-int4"])
|
||||
assert is_prequantized(catalog["google/gemma-4-12B-it-qat-int8"])
|
||||
|
||||
|
||||
def test_gemma4_12b_qat_entries_have_no_gguf():
|
||||
catalog = {m["name"]: m for m in get_models()}
|
||||
assert catalog["google/gemma-4-12B-it-qat-int4"]["gguf_sources"] == []
|
||||
assert catalog["google/gemma-4-12B-it-qat-int8"]["gguf_sources"] == []
|
||||
def test_gemma4_12b_qat_entries_have_no_gguf(publication_catalog):
|
||||
ranked = {r["name"]: r for r in rank_models(_8gb_vram_system(), search="gemma-4-12B-it-qat", limit=20)}
|
||||
for row in _qat_rows(publication_catalog):
|
||||
assert ranked[row["name"]]["gguf_sources"] == []
|
||||
|
||||
@@ -3,8 +3,33 @@
|
||||
The handler did `n = int(gpu_count)` with no guard, so `?gpu_count=abc` (or any
|
||||
non-integer) raised ValueError -> HTTP 500. A malformed count is now ignored,
|
||||
matching how the neighbouring gpu_group param is already parsed.
|
||||
|
||||
The count parsing runs only after the empty-catalog early return, so these
|
||||
tests rank the authored publication catalog against a fixed two-GPU system and
|
||||
spy on rank_models to prove the parsing path actually ran.
|
||||
"""
|
||||
import functools
|
||||
from copy import deepcopy
|
||||
|
||||
import pytest
|
||||
|
||||
from routes.hwfit_routes import setup_hwfit_routes
|
||||
from tests.hwfit_publication_fixtures import publication_catalog # noqa: F401
|
||||
|
||||
SYSTEM = {
|
||||
"has_gpu": True,
|
||||
"backend": "cuda",
|
||||
"gpu_name": "Synthetic GPU",
|
||||
"gpu_vram_gb": 24.0,
|
||||
"gpu_count": 2,
|
||||
"gpus": [
|
||||
{"index": 0, "name": "Synthetic GPU", "vram_gb": 12.0},
|
||||
{"index": 1, "name": "Synthetic GPU", "vram_gb": 12.0},
|
||||
],
|
||||
"gpu_groups": [{"name": "Synthetic GPU", "vram_each": 12.0, "count": 2, "indices": [0, 1], "vram_total": 24.0}],
|
||||
"available_ram_gb": 32.0,
|
||||
"total_ram_gb": 32.0,
|
||||
}
|
||||
|
||||
|
||||
def _get_models():
|
||||
@@ -15,24 +40,80 @@ def _get_models():
|
||||
raise AssertionError("hwfit /models route not found")
|
||||
|
||||
|
||||
def test_non_numeric_gpu_count_does_not_raise():
|
||||
@pytest.fixture
|
||||
def ranked_systems(publication_catalog, monkeypatch):
|
||||
"""Fix detection and record every system the handler hands to rank_models."""
|
||||
from services.hwfit import fit, hardware
|
||||
|
||||
monkeypatch.setattr(hardware, "detect_system", lambda **_: deepcopy(SYSTEM))
|
||||
calls = []
|
||||
real_rank = fit.rank_models
|
||||
|
||||
@functools.wraps(real_rank)
|
||||
def spy(system, **kwargs):
|
||||
calls.append(deepcopy(system))
|
||||
return real_rank(system, **kwargs)
|
||||
|
||||
monkeypatch.setattr(fit, "rank_models", spy)
|
||||
return calls
|
||||
|
||||
|
||||
def _assert_ranked(result, ranked_systems):
|
||||
assert isinstance(result, dict)
|
||||
assert "error" not in result
|
||||
assert isinstance(result["models"], list) and result["models"]
|
||||
# Reaching rank_models means the empty-catalog return was not taken and the
|
||||
# manual-hardware and gpu_count parsing ran first.
|
||||
assert len(ranked_systems) == 1
|
||||
assert ranked_systems[0] == result["system"]
|
||||
return result["system"]
|
||||
|
||||
|
||||
def test_non_numeric_gpu_count_does_not_raise(ranked_systems):
|
||||
handler = _get_models()
|
||||
# Previously raised ValueError (HTTP 500); now degrades to a normal ranking.
|
||||
result = handler(gpu_count="abc")
|
||||
assert isinstance(result, dict)
|
||||
system = _assert_ranked(handler(gpu_count="abc"), ranked_systems)
|
||||
# Ignored like an omitted count: the auto pool keeps both GPUs and no
|
||||
# explicit-count GPU-only pin is applied.
|
||||
assert system["detected_gpu_count"] == 2
|
||||
assert system["active_group"]["use_count"] == 2
|
||||
assert system["gpu_count"] == 2
|
||||
assert "gpu_only" not in system
|
||||
|
||||
|
||||
def test_numeric_gpu_count_still_accepted():
|
||||
def test_numeric_gpu_count_still_accepted(ranked_systems):
|
||||
handler = _get_models()
|
||||
result = handler(gpu_count="0")
|
||||
assert isinstance(result, dict)
|
||||
system = _assert_ranked(handler(gpu_count="0"), ranked_systems)
|
||||
# 0 switches to RAM-only ranking.
|
||||
assert system["detected_gpu_count"] == 2
|
||||
assert system["has_gpu"] is False
|
||||
assert system["gpu_count"] == 0
|
||||
assert system["gpu_only"] is False
|
||||
assert "active_group" not in system
|
||||
|
||||
|
||||
def test_non_numeric_manual_gpu_count_does_not_raise():
|
||||
def test_non_numeric_manual_gpu_count_does_not_raise(ranked_systems):
|
||||
# manual_gpu_count is the other count param on this endpoint (the hardware
|
||||
# simulator in _apply_manual_hardware). A non-numeric value must also degrade
|
||||
# (default to 1) rather than 500, so the endpoint's count parsing is fully
|
||||
# covered.
|
||||
handler = _get_models()
|
||||
result = handler(manual_mode="gpu", manual_gpu_count="abc")
|
||||
assert isinstance(result, dict)
|
||||
system = _assert_ranked(handler(manual_mode="gpu", manual_gpu_count="abc"), ranked_systems)
|
||||
assert system["manual_hardware"] is True
|
||||
assert system["gpu_name"] == "Simulated CUDA GPU"
|
||||
assert system["gpu_count"] == 1
|
||||
assert system["gpu_groups"][0]["count"] == 1
|
||||
assert system["detected_gpu_count"] == 1
|
||||
|
||||
|
||||
def test_empty_catalog_returns_before_count_parsing(ranked_systems, monkeypatch):
|
||||
# Control: without catalog rows the handler stops before the parsing above,
|
||||
# which is why the tests in this module must rank the authored catalog.
|
||||
from services.hwfit import models
|
||||
|
||||
monkeypatch.setattr(models, "get_models", lambda: [])
|
||||
result = _get_models()(gpu_count="abc", manual_mode="gpu", manual_gpu_count="abc")
|
||||
assert result["models"] == []
|
||||
assert "Model catalog is empty" in result["error"]
|
||||
assert ranked_systems == []
|
||||
assert "detected_gpu_count" not in result["system"]
|
||||
|
||||
+17
-10
@@ -6,9 +6,15 @@ guarantee that non-macOS (Linux/Windows) detection is unchanged.
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from services.hwfit import hardware
|
||||
from services.hwfit.fit import rank_models
|
||||
from services.hwfit.models import get_models
|
||||
from tests.hwfit_publication_fixtures import publication_catalog # noqa: F401
|
||||
|
||||
# Rank authored inputs rather than publication catalog snapshots.
|
||||
pytestmark = pytest.mark.usefixtures("publication_catalog")
|
||||
|
||||
|
||||
def _metal_system(ram_gb=16.0, vram_gb=10.7):
|
||||
@@ -78,19 +84,20 @@ def test_only_gguf_or_mlx_models_recommended_on_metal():
|
||||
assert unservable == [], f"{len(unservable)} non-servable models on Metal, e.g. {unservable[:3]}"
|
||||
|
||||
|
||||
def test_qwen_catalog_entries_point_at_verified_gguf_repos():
|
||||
"""Qwen GGUF-looking Cookbook rows must download GGUF repos, not the base
|
||||
safetensors repositories."""
|
||||
catalog = {m["name"]: m for m in get_models()}
|
||||
def test_qwen_gguf_rows_rank_with_their_gguf_downloads_on_metal(publication_catalog):
|
||||
"""Qwen GGUF-looking Cookbook rows must download the GGUF repo/file their
|
||||
catalog row names, not the base safetensors repository."""
|
||||
expected = {
|
||||
"Qwen/Qwen3.5-9B": ("unsloth/Qwen3.5-9B-GGUF", "Qwen3.5-9B-Q4_K_M.gguf"),
|
||||
"Qwen/Qwen3.6-27B": ("unsloth/Qwen3.6-27B-GGUF", "Qwen3.6-27B-Q4_K_M.gguf"),
|
||||
"Qwen/Qwen3.6-35B-A3B": ("unsloth/Qwen3.6-35B-A3B-GGUF", "Qwen3.6-35B-A3B-UD-Q4_K_M.gguf"),
|
||||
r["name"]: r["gguf_sources"]
|
||||
for r in publication_catalog
|
||||
if r["name"].startswith("Qwen/Qwen3.") and r["gguf_sources"]
|
||||
}
|
||||
assert len(expected) == 3
|
||||
ranked = {r["name"]: r for r in rank_models(_metal_system(), search="Qwen/Qwen3.", limit=50)}
|
||||
|
||||
for model_name, (repo, filename) in expected.items():
|
||||
sources = catalog[model_name].get("gguf_sources") or []
|
||||
assert any(src.get("repo") == repo and src.get("file") == filename for src in sources)
|
||||
for model_name, sources in expected.items():
|
||||
assert ranked[model_name]["gguf_sources"] == sources
|
||||
assert all(src["repo"] != model_name and src["file"].endswith(".gguf") for src in ranked[model_name]["gguf_sources"])
|
||||
|
||||
|
||||
def test_safetensors_models_still_recommended_on_cuda():
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import pytest
|
||||
|
||||
from services.hwfit.fit import analyze_model, rank_models
|
||||
from services.hwfit.models import (
|
||||
get_models,
|
||||
infer_quantization_from_name,
|
||||
is_prequantized,
|
||||
)
|
||||
from tests.hwfit_publication_fixtures import publication_catalog # noqa: F401
|
||||
|
||||
# Rank authored inputs rather than publication catalog snapshots.
|
||||
pytestmark = pytest.mark.usefixtures("publication_catalog")
|
||||
|
||||
|
||||
def _dual_5060ti_system():
|
||||
@@ -35,7 +41,7 @@ def test_infers_native_hf_quant_formats_from_repo_names():
|
||||
|
||||
def test_nvfp4_catalog_quant_is_preserved():
|
||||
catalog = {m["name"]: m for m in get_models()}
|
||||
model = catalog["txn545/Qwen3.5-122B-A10B-NVFP4"]
|
||||
model = catalog["test/native-NVFP4"]
|
||||
|
||||
assert model["quantization"] == "NVFP4"
|
||||
assert is_prequantized(model)
|
||||
@@ -43,7 +49,7 @@ def test_nvfp4_catalog_quant_is_preserved():
|
||||
|
||||
def test_nvfp4_search_result_is_not_gguf_or_cpu_offload():
|
||||
catalog = {m["name"]: m for m in get_models()}
|
||||
model = catalog["txn545/Qwen3.5-122B-A10B-NVFP4"]
|
||||
model = catalog["test/native-NVFP4"]
|
||||
|
||||
fit = analyze_model(model, _dual_5060ti_system())
|
||||
assert fit["quant"] == "NVFP4"
|
||||
@@ -51,10 +57,10 @@ def test_nvfp4_search_result_is_not_gguf_or_cpu_offload():
|
||||
|
||||
results = rank_models(
|
||||
_dual_5060ti_system(),
|
||||
search="Qwen3.5-122B-A10B-NVFP4",
|
||||
search="native-NVFP4",
|
||||
limit=10,
|
||||
)
|
||||
hit = next(r for r in results if r["name"] == "txn545/Qwen3.5-122B-A10B-NVFP4")
|
||||
hit = next(r for r in results if r["name"] == "test/native-NVFP4")
|
||||
assert hit["quant"] == "NVFP4"
|
||||
assert hit["run_mode"] != "cpu_offload"
|
||||
|
||||
|
||||
@@ -281,7 +281,7 @@ def test_open_imap_connection_shuts_down_on_starttls_failure(monkeypatch):
|
||||
))
|
||||
conn.starttls = MagicMock(side_effect=RuntimeError("STARTTLS rejected"))
|
||||
|
||||
monkeypatch.setattr(helpers.imaplib, "IMAP4", lambda *a, **kw: conn)
|
||||
monkeypatch.setattr(helpers, "_PolicyIMAP4", lambda *a, **kw: conn)
|
||||
|
||||
raised = False
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Regression tests for SSRF hardening on the manage_endpoints agent tool.
|
||||
|
||||
Verifies that the do_manage_endpoints tool function rejects user-supplied URLs
|
||||
that resolve to link-local (cloud metadata), non-HTTP schemes, or (when
|
||||
MODELENDPOINT_BLOCK_PRIVATE_IPS=true) private/loopback addresses -- applying
|
||||
the shared outbound-URL policy in check_outbound_url(). The HTTP route
|
||||
(POST /api/model-endpoints) receives the same guard in #6316.
|
||||
|
||||
The agent tool path bypasses the HTTP route entirely, so it needs its own
|
||||
validation. A prompt-injection payload smuggled into a skill, note, fetched
|
||||
page, or email could instruct the model to register a cloud-metadata or
|
||||
internal-network endpoint via this tool.
|
||||
"""
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
import src.url_safety as _url_safety
|
||||
|
||||
_real_check_outbound_url = _url_safety.check_outbound_url
|
||||
|
||||
|
||||
def _fake_resolver(host):
|
||||
"""Resolver that returns deterministic IPs without real DNS."""
|
||||
_HOST_MAP = {
|
||||
"169.254.169.254": ["169.254.169.254"],
|
||||
"metadata.google.internal": ["169.254.169.254"],
|
||||
"localhost": ["127.0.0.1"],
|
||||
"127.0.0.1": ["127.0.0.1"],
|
||||
"0.0.0.0": ["0.0.0.0"],
|
||||
"example.com": ["93.184.216.34"],
|
||||
"192.168.1.1": ["192.168.1.1"],
|
||||
"10.0.0.1": ["10.0.0.1"],
|
||||
"api.openai.com": ["104.18.6.192"],
|
||||
}
|
||||
if host in _HOST_MAP:
|
||||
return _HOST_MAP[host]
|
||||
try:
|
||||
ipaddress.ip_address(host)
|
||||
return [host]
|
||||
except ValueError:
|
||||
pass
|
||||
raise OSError(f"Name or service not known: {host}")
|
||||
|
||||
|
||||
def _check_with_fake_resolver(url, *, block_private=False, resolver=None):
|
||||
"""Call the real check_outbound_url with our fake resolver."""
|
||||
return _real_check_outbound_url(url, block_private=block_private, resolver=_fake_resolver)
|
||||
|
||||
|
||||
def _make_content(base_url, *, name="", api_key=""):
|
||||
"""Build a JSON content string for the manage_endpoints 'add' action."""
|
||||
return json.dumps({
|
||||
"action": "add",
|
||||
"name": name,
|
||||
"base_url": base_url,
|
||||
"api_key": api_key,
|
||||
})
|
||||
|
||||
|
||||
class _FakeQuery:
|
||||
def __init__(self, model):
|
||||
self._model = model
|
||||
|
||||
def filter(self, *args):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return None
|
||||
|
||||
def all(self):
|
||||
return []
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
def __init__(self):
|
||||
self.added = []
|
||||
self.committed = False
|
||||
|
||||
def query(self, model):
|
||||
return _FakeQuery(model)
|
||||
|
||||
def add(self, obj):
|
||||
self.added.append(obj)
|
||||
|
||||
def commit(self):
|
||||
self.committed = True
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _FakeModelEndpoint:
|
||||
def __init__(self, **kwargs):
|
||||
for k, v in kwargs.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
|
||||
def _setup_mocks(monkeypatch, db):
|
||||
"""Set up faked core.database and patched check_outbound_url."""
|
||||
fake_core_db = types.ModuleType("core.database")
|
||||
fake_core_db.SessionLocal = lambda: db
|
||||
fake_core_db.ModelEndpoint = _FakeModelEndpoint
|
||||
monkeypatch.setitem(sys.modules, "core.database", fake_core_db)
|
||||
|
||||
monkeypatch.setattr(_url_safety, "check_outbound_url", _check_with_fake_resolver)
|
||||
|
||||
if "src.agent_tools.admin_tools" in sys.modules:
|
||||
monkeypatch.delitem(sys.modules, "src.agent_tools.admin_tools", raising=False)
|
||||
|
||||
|
||||
def _get_do_manage_endpoints():
|
||||
"""Import and return do_manage_endpoints from the reloaded admin_tools."""
|
||||
from src.agent_tools.admin_tools import do_manage_endpoints
|
||||
return do_manage_endpoints
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_metadata_blocked(monkeypatch):
|
||||
"""Cloud metadata IP (169.254.169.254) must be rejected."""
|
||||
db = _FakeDB()
|
||||
_setup_mocks(monkeypatch, db)
|
||||
do_manage_endpoints = _get_do_manage_endpoints()
|
||||
content = _make_content("http://169.254.169.254/latest/meta-data/")
|
||||
result = await do_manage_endpoints(content, owner="admin")
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Rejected" in result["error"]
|
||||
assert len(db.added) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_http_scheme_blocked(monkeypatch):
|
||||
"""Non-HTTP schemes (file://, gopher://) must be rejected."""
|
||||
db = _FakeDB()
|
||||
_setup_mocks(monkeypatch, db)
|
||||
do_manage_endpoints = _get_do_manage_endpoints()
|
||||
content = _make_content("file:///etc/passwd")
|
||||
result = await do_manage_endpoints(content, owner="admin")
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Rejected" in result["error"]
|
||||
assert len(db.added) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_loopback_accepted_by_default(monkeypatch):
|
||||
"""Loopback addresses are accepted by default (local-first design).
|
||||
|
||||
The agent tool uses check_outbound_url directly, which allows
|
||||
loopback by default -- this is correct for local model endpoints
|
||||
(Ollama, llama.cpp, etc.). Loopback is only blocked in strict mode.
|
||||
"""
|
||||
db = _FakeDB()
|
||||
_setup_mocks(monkeypatch, db)
|
||||
do_manage_endpoints = _get_do_manage_endpoints()
|
||||
content = _make_content("http://localhost:11434/v1")
|
||||
result = await do_manage_endpoints(content, owner="admin")
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "Added endpoint" in result["response"]
|
||||
assert len(db.added) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strict_mode_blocks_private(monkeypatch):
|
||||
"""MODELENDPOINT_BLOCK_PRIVATE_IPS=true blocks private IPs."""
|
||||
db = _FakeDB()
|
||||
_setup_mocks(monkeypatch, db)
|
||||
monkeypatch.setenv("MODELENDPOINT_BLOCK_PRIVATE_IPS", "true")
|
||||
do_manage_endpoints = _get_do_manage_endpoints()
|
||||
content = _make_content("http://192.168.1.1:8080/v1")
|
||||
result = await do_manage_endpoints(content, owner="admin")
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Rejected" in result["error"]
|
||||
assert len(db.added) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_safe_url_accepted(monkeypatch):
|
||||
"""A safe public URL should be accepted and stored."""
|
||||
db = _FakeDB()
|
||||
_setup_mocks(monkeypatch, db)
|
||||
do_manage_endpoints = _get_do_manage_endpoints()
|
||||
content = _make_content("https://api.openai.com/v1", name="OpenAI")
|
||||
result = await do_manage_endpoints(content, owner="admin")
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "Added endpoint" in result["response"]
|
||||
assert len(db.added) == 1
|
||||
assert db.committed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_url_rejected(monkeypatch):
|
||||
"""An empty base_url must be rejected before SSRF check."""
|
||||
db = _FakeDB()
|
||||
_setup_mocks(monkeypatch, db)
|
||||
do_manage_endpoints = _get_do_manage_endpoints()
|
||||
content = _make_content("")
|
||||
result = await do_manage_endpoints(content, owner="admin")
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "base_url is required" in result["error"]
|
||||
assert len(db.added) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gopher_scheme_blocked(monkeypatch):
|
||||
"""gopher:// scheme must be rejected."""
|
||||
db = _FakeDB()
|
||||
_setup_mocks(monkeypatch, db)
|
||||
do_manage_endpoints = _get_do_manage_endpoints()
|
||||
content = _make_content("gopher://evil:6379/secret")
|
||||
result = await do_manage_endpoints(content, owner="admin")
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Rejected" in result["error"]
|
||||
assert len(db.added) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_not_leaked(monkeypatch):
|
||||
"""Error messages must not leak raw exception text."""
|
||||
db = _FakeDB()
|
||||
_setup_mocks(monkeypatch, db)
|
||||
do_manage_endpoints = _get_do_manage_endpoints()
|
||||
content = _make_content("http://169.254.169.254/latest/meta-data/")
|
||||
result = await do_manage_endpoints(content, owner="admin")
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Rejected" in result["error"]
|
||||
assert "Traceback" not in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ipv6_loopback_accepted_by_default(monkeypatch):
|
||||
"""::1 must be treated as loopback (accepted local-first), not reserved.
|
||||
|
||||
Proves this branch carries the dfd1f7e7 url_safety fix after rebasing
|
||||
onto current dev: the pre-fix guard checked is_reserved before is_loopback
|
||||
and rejected ::1 even in default local-first mode.
|
||||
"""
|
||||
db = _FakeDB()
|
||||
_setup_mocks(monkeypatch, db)
|
||||
do_manage_endpoints = _get_do_manage_endpoints()
|
||||
content = _make_content("http://[::1]:11434/v1")
|
||||
result = await do_manage_endpoints(content, owner="admin")
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "Added endpoint" in result["response"]
|
||||
assert len(db.added) == 1
|
||||
@@ -0,0 +1,289 @@
|
||||
"""manage_tasks must apply the admin-only task-action policy.
|
||||
|
||||
``POST /api/tasks`` (routes/task/task_routes.py) and the scheduler
|
||||
(src/task_scheduler.py) both refuse ``action`` tasks whose action is in
|
||||
``ADMIN_ONLY_TASK_ACTIONS`` (run_local, run_script, ssh_command,
|
||||
cookbook_serve) for owners without admin task privileges — those actions run
|
||||
``subprocess.run(shell=True)`` or SSH with no sandbox. The model-facing
|
||||
``do_manage_tasks`` applied neither check, so an agent could store such a task
|
||||
on create/edit and resume/run it; the scheduler would only pause it at
|
||||
execution time. Regression for issue #6021: the tool now enforces the same
|
||||
policy on create, edit, resume and run, and keeps allowing admins and the
|
||||
non-privileged actions.
|
||||
"""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from tests.helpers.import_state import clear_fake_database_modules
|
||||
|
||||
clear_fake_database_modules()
|
||||
|
||||
import core.database as cdb
|
||||
from core.database import ScheduledTask
|
||||
import src.task_action_policy as policy_mod
|
||||
import src.tools.system as system_mod
|
||||
from src.tools.system import do_manage_tasks
|
||||
|
||||
_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
_ENGINE = create_engine(
|
||||
f"sqlite:///{_TMPDB.name}",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=NullPool,
|
||||
)
|
||||
cdb.Base.metadata.create_all(_ENGINE)
|
||||
_TS = sessionmaker(bind=_ENGINE, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _bind_session_factory(monkeypatch):
|
||||
# do_manage_tasks does `from core.database import SessionLocal` at call
|
||||
# time. Bind it per test (not at import) so this file stays
|
||||
# order-independent from sibling files that point it at their own DB.
|
||||
monkeypatch.setattr(cdb, "SessionLocal", _TS)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def privileges(monkeypatch):
|
||||
"""Drive owner_has_admin_task_privileges from a plain dict."""
|
||||
admins = {"root"}
|
||||
monkeypatch.setattr(
|
||||
policy_mod, "owner_has_admin_task_privileges",
|
||||
lambda owner: owner in admins,
|
||||
)
|
||||
return admins
|
||||
|
||||
|
||||
def _seed(task_id, owner, task_type="llm", action=None, status="active"):
|
||||
db = _TS()
|
||||
try:
|
||||
db.add(ScheduledTask(
|
||||
id=task_id, owner=owner, name=task_id, prompt="original",
|
||||
task_type=task_type, action=action, trigger_type="webhook",
|
||||
status=status, output_target="session",
|
||||
))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _get(task_id):
|
||||
db = _TS()
|
||||
try:
|
||||
return db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _count(owner):
|
||||
db = _TS()
|
||||
try:
|
||||
return db.query(ScheduledTask).filter(ScheduledTask.owner == owner).count()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("action_name", sorted(policy_mod.ADMIN_ONLY_TASK_ACTIONS))
|
||||
async def test_create_admin_only_action_denied_for_non_admin(privileges, action_name):
|
||||
before = _count("alice")
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "create", "task_type": "action", "action_name": action_name,
|
||||
"name": "pwn", "schedule": "daily", "scheduled_time": "09:00",
|
||||
}), owner="alice")
|
||||
assert out["exit_code"] == 1, out
|
||||
assert "requires admin privileges" in out["error"]
|
||||
assert _count("alice") == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_admin_only_action_allowed_for_admin(privileges):
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "create", "task_type": "action", "action_name": "run_local",
|
||||
"name": "ok", "schedule": "daily", "scheduled_time": "09:00",
|
||||
}), owner="root")
|
||||
assert out["exit_code"] == 0, out
|
||||
assert _get(out["task_id"]).action == "run_local"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_regular_action_allowed_for_non_admin(privileges):
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "create", "task_type": "action", "action_name": "tidy_sessions",
|
||||
"name": "ok", "schedule": "daily", "scheduled_time": "09:00",
|
||||
}), owner="alice")
|
||||
assert out["exit_code"] == 0, out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_llm_task_allowed_for_non_admin(privileges):
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "create", "task_type": "llm", "prompt": "summarize",
|
||||
"schedule": "daily", "scheduled_time": "09:00",
|
||||
}), owner="alice")
|
||||
assert out["exit_code"] == 0, out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_cannot_turn_llm_task_into_shell_action(privileges):
|
||||
_seed("alice-llm", "alice")
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "edit", "task_id": "alice-llm",
|
||||
"task_type": "action", "action_name": "ssh_command",
|
||||
}), owner="alice")
|
||||
assert out["exit_code"] == 1, out
|
||||
assert "requires admin privileges" in out["error"]
|
||||
task = _get("alice-llm")
|
||||
assert task.task_type == "llm" and task.action is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_cannot_swap_action_name_to_admin_only(privileges):
|
||||
_seed("alice-action", "alice", task_type="action", action="tidy_sessions")
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "edit", "task_id": "alice-action", "action_name": "run_script",
|
||||
}), owner="alice")
|
||||
assert out["exit_code"] == 1, out
|
||||
assert _get("alice-action").action == "tidy_sessions"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_of_unrelated_field_on_admin_only_task_denied_for_non_admin(privileges):
|
||||
# A task that slipped in before the policy (or via demotion of its owner)
|
||||
# cannot be kept alive by a non-admin: the route rejects the same edit.
|
||||
_seed("alice-legacy", "alice", task_type="action", action="run_local")
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "edit", "task_id": "alice-legacy", "name": "renamed",
|
||||
}), owner="alice")
|
||||
assert out["exit_code"] == 1, out
|
||||
assert _get("alice-legacy").name == "alice-legacy"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_admin_only_task_denied_for_non_admin(privileges):
|
||||
_seed("alice-paused", "alice", task_type="action", action="run_local", status="paused")
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "resume", "task_id": "alice-paused",
|
||||
}), owner="alice")
|
||||
assert out["exit_code"] == 1, out
|
||||
assert _get("alice-paused").status == "paused"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pause_admin_only_task_still_allowed(privileges):
|
||||
# Narrowing is always fine: a non-admin may pause a task they own.
|
||||
_seed("alice-active", "alice", task_type="action", action="run_local")
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "pause", "task_id": "alice-active",
|
||||
}), owner="alice")
|
||||
assert out["exit_code"] == 0, out
|
||||
assert _get("alice-active").status == "paused"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_admin_only_task_denied_before_scheduler(privileges, monkeypatch):
|
||||
_seed("alice-run", "alice", task_type="action", action="ssh_command")
|
||||
calls = []
|
||||
|
||||
class _Sched:
|
||||
async def run_task_now(self, task_id):
|
||||
calls.append(task_id)
|
||||
return True
|
||||
|
||||
import src.event_bus as event_bus
|
||||
monkeypatch.setattr(event_bus, "get_task_scheduler", lambda: _Sched())
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "run", "task_id": "alice-run",
|
||||
}), owner="alice")
|
||||
assert out["exit_code"] == 1, out
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_admin_only_task_allowed_for_admin(privileges, monkeypatch):
|
||||
_seed("root-run", "root", task_type="action", action="ssh_command")
|
||||
calls = []
|
||||
|
||||
class _Sched:
|
||||
async def run_task_now(self, task_id):
|
||||
calls.append(task_id)
|
||||
return True
|
||||
|
||||
import src.event_bus as event_bus
|
||||
monkeypatch.setattr(event_bus, "get_task_scheduler", lambda: _Sched())
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "run", "task_id": "root-run",
|
||||
}), owner="root")
|
||||
assert out["exit_code"] == 0, out
|
||||
assert calls == ["root-run"]
|
||||
|
||||
|
||||
def test_policy_helper_uses_shared_constants(privileges):
|
||||
# The tool must consult the shared policy module, not a private copy of
|
||||
# the action names, so a new admin-only action is covered everywhere.
|
||||
assert system_mod._admin_task_action_denied("alice", "action", "run_local") is not None
|
||||
assert system_mod._admin_task_action_denied("root", "action", "run_local") is None
|
||||
assert system_mod._admin_task_action_denied("alice", "action", "tidy_sessions") is None
|
||||
assert system_mod._admin_task_action_denied("alice", "llm", "run_local") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_admin_only_task_by_name_denied_for_non_admin(privileges, monkeypatch):
|
||||
_seed("alice-run-name", "alice", task_type="action", action="ssh_command")
|
||||
calls = []
|
||||
|
||||
class _Sched:
|
||||
async def run_task_now(self, task_id):
|
||||
calls.append(task_id)
|
||||
return True
|
||||
|
||||
import src.event_bus as event_bus
|
||||
monkeypatch.setattr(event_bus, "get_task_scheduler", lambda: _Sched())
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "run", "name": "alice-run-name",
|
||||
}), owner="alice")
|
||||
assert out["exit_code"] == 1, out
|
||||
assert "requires admin privileges" in out["error"]
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_admin_only_action_seals_authority_for_admin(privileges):
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "create", "task_type": "action", "action_name": "run_local",
|
||||
"name": "sealed-task", "schedule": "daily", "scheduled_time": "09:00",
|
||||
}), owner="root")
|
||||
assert out["exit_code"] == 0, out
|
||||
task = _get(out["task_id"])
|
||||
assert task.action == "run_local"
|
||||
assert task.request_authority_json is not None
|
||||
|
||||
|
||||
def test_real_owner_has_admin_task_privileges_branches(monkeypatch):
|
||||
"""Verify owner_has_admin_task_privileges without mocking."""
|
||||
# Auth disabled branch
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
assert policy_mod.owner_has_admin_task_privileges("alice") is True
|
||||
assert policy_mod.owner_has_admin_task_privileges(None) is True
|
||||
|
||||
# Auth enabled branch with configured AuthManager
|
||||
monkeypatch.delenv("AUTH_ENABLED", raising=False)
|
||||
|
||||
class FakeAuth:
|
||||
is_configured = True
|
||||
|
||||
def is_admin(self, u):
|
||||
return u == "admin"
|
||||
|
||||
import core.auth as c_auth
|
||||
monkeypatch.setattr(c_auth, "AuthManager", lambda: FakeAuth())
|
||||
|
||||
assert policy_mod.owner_has_admin_task_privileges("admin") is True
|
||||
assert policy_mod.owner_has_admin_task_privileges("alice") is False
|
||||
assert policy_mod.owner_has_admin_task_privileges("internal-tool") is True
|
||||
assert policy_mod.owner_has_admin_task_privileges(None) is False
|
||||
@@ -264,3 +264,231 @@ def test_launchers_export_the_port_they_serve_on():
|
||||
text = (_repo_root() / name).read_text(encoding="utf-8")
|
||||
assert uvicorn_flag in text, f"{name}: launcher no longer passes {uvicorn_flag}"
|
||||
assert export in text, f"{name}: serves on a port the app cannot read back"
|
||||
|
||||
|
||||
# ── Callback & Exchange Expired State Handling ───────────────────
|
||||
|
||||
def test_oauth_callback_expired_state_returns_dedicated_expired_page(monkeypatch):
|
||||
"""An unknown or expired generic MCP OAuth state in the callback must return
|
||||
the dedicated 'Authorization Expired' page instead of a bare 403 'Admin only'
|
||||
or a 404 'Server not found'."""
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.mcp import mcp_routes
|
||||
|
||||
# Simulate unauthenticated request that would fail admin check if invoked
|
||||
def _deny_admin(request):
|
||||
raise HTTPException(403, "Admin only")
|
||||
|
||||
monkeypatch.setattr(mcp_routes, "require_admin", _deny_admin)
|
||||
|
||||
# Empty DB / no matching server
|
||||
class FakeQuery:
|
||||
def filter(self, *a):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return None
|
||||
|
||||
class FakeSession:
|
||||
def query(self, *a):
|
||||
return FakeQuery()
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: FakeSession())
|
||||
|
||||
app = FastAPI()
|
||||
manager = MagicMock()
|
||||
router = mcp_routes.setup_mcp_routes(manager)
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
# Both code and state are provided, but state is not in _pending (expired/unknown)
|
||||
resp = client.get("/api/mcp/oauth/callback?code=some-code&state=expired-state-123")
|
||||
assert resp.status_code == 400
|
||||
assert "Authorization Expired" in resp.text
|
||||
assert (
|
||||
"This authorization round has expired. Press Reconnect in Odysseus and try again."
|
||||
in resp.text
|
||||
)
|
||||
# Ensure it did not return the bare 403 "Admin only"
|
||||
assert "Admin only" not in resp.text
|
||||
|
||||
|
||||
def test_oauth_callback_active_pending_state_succeeds_without_admin(monkeypatch):
|
||||
"""An active pending generic MCP OAuth state resolves without requiring admin auth."""
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.mcp import mcp_routes
|
||||
|
||||
# Admin check fails if invoked
|
||||
def _deny_admin(request):
|
||||
raise HTTPException(403, "Admin only")
|
||||
|
||||
monkeypatch.setattr(mcp_routes, "require_admin", _deny_admin)
|
||||
|
||||
app = FastAPI()
|
||||
manager = MagicMock()
|
||||
router = mcp_routes.setup_mcp_routes(manager)
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
async def go():
|
||||
fut = mcp_oauth.register_pending("pending-round-1")
|
||||
resp = client.get("/api/mcp/oauth/callback?code=auth-code-xyz&state=pending-round-1")
|
||||
assert resp.status_code == 200
|
||||
assert "Authorization Successful" in resp.text
|
||||
code, state = await asyncio.wait_for(fut, timeout=1)
|
||||
assert code == "auth-code-xyz"
|
||||
assert state == "pending-round-1"
|
||||
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_oauth_callback_legacy_google_server_requires_admin(monkeypatch):
|
||||
"""Legacy Google OAuth callbacks (where state is server_id of a server with
|
||||
oauth_config) still enforce require_admin."""
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.mcp import mcp_routes
|
||||
from core.database import McpServer
|
||||
|
||||
# Server exists in DB with Google oauth_config
|
||||
google_srv = McpServer(
|
||||
id="google-srv-1",
|
||||
name="Google Workspace",
|
||||
oauth_config=json.dumps({"provider": "google", "keys_file": "k.json", "token_file": "t.json"}),
|
||||
)
|
||||
|
||||
class FakeQuery:
|
||||
def filter(self, *a):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return google_srv
|
||||
|
||||
class FakeSession:
|
||||
def query(self, *a):
|
||||
return FakeQuery()
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: FakeSession())
|
||||
|
||||
# When require_admin denies, 403 is raised for the legacy Google path
|
||||
def _deny_admin(request):
|
||||
raise HTTPException(403, "Admin only")
|
||||
|
||||
monkeypatch.setattr(mcp_routes, "require_admin", _deny_admin)
|
||||
|
||||
app = FastAPI()
|
||||
manager = MagicMock()
|
||||
router = mcp_routes.setup_mcp_routes(manager)
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.get("/api/mcp/oauth/callback?code=code-123&state=google-srv-1")
|
||||
assert resp.status_code == 403
|
||||
assert "Admin only" in resp.text
|
||||
|
||||
|
||||
def test_oauth_exchange_expired_generic_state_returns_expired_page(monkeypatch):
|
||||
"""Remote paste-back flow for a generic MCP server returns the dedicated expired
|
||||
page when the state is no longer pending, instead of 'No OAuth config.'."""
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.mcp import mcp_routes
|
||||
from core.database import McpServer
|
||||
|
||||
monkeypatch.setattr(mcp_routes, "require_admin", lambda r: None)
|
||||
|
||||
# Server exists in DB without oauth_config (generic MCP server)
|
||||
generic_srv = McpServer(
|
||||
id="http-srv-1",
|
||||
name="Remote HTTP MCP",
|
||||
oauth_config=None,
|
||||
)
|
||||
|
||||
class FakeQuery:
|
||||
def filter(self, *a):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return generic_srv
|
||||
|
||||
class FakeSession:
|
||||
def query(self, *a):
|
||||
return FakeQuery()
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: FakeSession())
|
||||
|
||||
app = FastAPI()
|
||||
manager = MagicMock()
|
||||
router = mcp_routes.setup_mcp_routes(manager)
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.post(
|
||||
"/api/mcp/oauth/exchange/http-srv-1",
|
||||
data={"callback_url": "http://localhost:7000/api/mcp/oauth/callback?code=code-1&state=expired-state"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Authorization Expired" in resp.text
|
||||
assert (
|
||||
"This authorization round has expired. Press Reconnect in Odysseus and try again."
|
||||
in resp.text
|
||||
)
|
||||
assert "No OAuth config." not in resp.text
|
||||
|
||||
|
||||
def test_oauth_callback_missing_state_returns_malformed_error_not_expired():
|
||||
"""Missing state parameter in callback must return a bad-request error and must
|
||||
not be treated as an expired authorization round."""
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.mcp import mcp_routes
|
||||
|
||||
app = FastAPI()
|
||||
manager = MagicMock()
|
||||
router = mcp_routes.setup_mcp_routes(manager)
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.get("/api/mcp/oauth/callback?code=some-code")
|
||||
assert resp.status_code == 400
|
||||
assert "No OAuth state parameter found in the callback URL." in resp.text
|
||||
assert "Authorization Expired" not in resp.text
|
||||
assert "This authorization round has expired" not in resp.text
|
||||
|
||||
|
||||
def test_oauth_callback_missing_code_returns_malformed_error_not_expired():
|
||||
"""Missing code parameter in callback must return a bad-request error and must
|
||||
not be treated as an expired authorization round."""
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.mcp import mcp_routes
|
||||
|
||||
app = FastAPI()
|
||||
manager = MagicMock()
|
||||
router = mcp_routes.setup_mcp_routes(manager)
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.get("/api/mcp/oauth/callback?state=some-state")
|
||||
assert resp.status_code == 400
|
||||
assert "No authorization code found in the URL." in resp.text
|
||||
assert "Authorization Expired" not in resp.text
|
||||
assert "This authorization round has expired" not in resp.text
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Regression tests for SSRF hardening on model endpoint routes.
|
||||
|
||||
Verifies that POST /model-endpoints/test, POST /model-endpoints, and
|
||||
PATCH /model-endpoints/{ep_id} reject user-supplied URLs that resolve to
|
||||
link-local (cloud metadata), non-HTTP schemes, or (when
|
||||
MODELENDPOINT_BLOCK_PRIVATE_IPS=true) private/loopback addresses — matching
|
||||
the existing SSRF guard pattern in embedding_routes.py.
|
||||
|
||||
A stub resolver is injected so the loopback tests never touch real DNS.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ── Resolver stub (same pattern as tests/test_url_safety.py) ────────────────
|
||||
def _resolver(mapping):
|
||||
"""Create a stub resolver for deterministic DNS in tests."""
|
||||
def resolve(host):
|
||||
if host in mapping:
|
||||
return mapping[host]
|
||||
raise OSError(f"unresolvable: {host}")
|
||||
return resolve
|
||||
|
||||
|
||||
LOCALHOST_V4 = _resolver({"localhost": ["127.0.0.1"]})
|
||||
LOCALHOST_V6 = _resolver({"localhost": ["::1"]})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
"""Build a TestClient with auth disabled and minimal DB stubs."""
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
|
||||
|
||||
# Stub heavy deps that model_routes imports at module level.
|
||||
# Use monkeypatch so entries are removed after the test.
|
||||
for mod in [
|
||||
"src.tls_overrides",
|
||||
"src.llm_core",
|
||||
"src.settings",
|
||||
"src.constants",
|
||||
"core.log_safety",
|
||||
]:
|
||||
if mod not in sys.modules:
|
||||
monkeypatch.setitem(sys.modules, mod, MagicMock())
|
||||
|
||||
# Provide real endpoint_resolver so the route logic works
|
||||
if "src.endpoint_resolver" not in sys.modules or isinstance(
|
||||
sys.modules["src.endpoint_resolver"], MagicMock
|
||||
):
|
||||
er = MagicMock()
|
||||
er.normalize_base = lambda url: url
|
||||
er.resolve_url = lambda url: url
|
||||
er.build_chat_url = lambda base, **kw: base
|
||||
er.build_models_url = lambda base, **kw: base + "/v1/models"
|
||||
er.build_headers = lambda base, **kw: {}
|
||||
monkeypatch.setitem(sys.modules, "src.endpoint_resolver", er)
|
||||
|
||||
# Clear cached model_routes module so it picks up our stubs
|
||||
monkeypatch.delitem(sys.modules, "routes.model_routes", raising=False)
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
import routes.model_routes as mr
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
router = mr.setup_model_routes(model_discovery=None)
|
||||
app.include_router(router)
|
||||
app.state.auth_enabled = False
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
class TestModelEndpointSSRF:
|
||||
"""SSRF protection for POST /model-endpoints/test."""
|
||||
|
||||
def test_cloud_metadata_blocked(self, client):
|
||||
"""Cloud metadata IP (169.254.169.254) must be rejected."""
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "http://169.254.169.254/latest/meta-data/"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
|
||||
def test_non_http_scheme_blocked(self, client):
|
||||
"""Non-HTTP schemes (file://, ftp://) must be rejected."""
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "file:///etc/passwd"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
|
||||
def test_loopback_accepted_by_default(self, client):
|
||||
"""Local-first: loopback is accepted when block_private=False."""
|
||||
with patch("src.url_safety._default_resolver", LOCALHOST_V4):
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "http://localhost:11434/v1"},
|
||||
)
|
||||
# Guard passes; probe fails (no server) but that is not SSRF.
|
||||
assert "Rejected endpoint URL" not in str(resp.json())
|
||||
|
||||
def test_loopback_rejected_in_strict_mode(self, client):
|
||||
"""Strict mode: loopback is rejected when block_private=True."""
|
||||
os.environ["MODELENDPOINT_BLOCK_PRIVATE_IPS"] = "true"
|
||||
try:
|
||||
with patch("src.url_safety._default_resolver", LOCALHOST_V4):
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "http://localhost:11434/v1"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
finally:
|
||||
os.environ.pop("MODELENDPOINT_BLOCK_PRIVATE_IPS", None)
|
||||
|
||||
def test_strict_mode_blocks_direct_loopback_ip(self, client):
|
||||
"""Strict mode rejects 127.0.0.1 passed directly (no DNS needed)."""
|
||||
os.environ["MODELENDPOINT_BLOCK_PRIVATE_IPS"] = "true"
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "http://127.0.0.1:11434/v1"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
finally:
|
||||
os.environ.pop("MODELENDPOINT_BLOCK_PRIVATE_IPS", None)
|
||||
|
||||
def test_ipv6_loopback_literal_accepted_by_default(self, client):
|
||||
"""::1 is loopback, not a disallowed 'reserved' address (dfd1f7e7)."""
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "http://[::1]:11434/v1"},
|
||||
)
|
||||
assert "Rejected endpoint URL" not in str(resp.json())
|
||||
|
||||
def test_ipv6_loopback_localhost_accepted_by_default(self, client):
|
||||
"""Dual-stack hosts whose resolver answers ::1 first must work."""
|
||||
with patch("src.url_safety._default_resolver", LOCALHOST_V6):
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "http://localhost:11434/v1"},
|
||||
)
|
||||
assert "Rejected endpoint URL" not in str(resp.json())
|
||||
|
||||
def test_ipv6_loopback_rejected_in_strict_mode(self, client):
|
||||
"""Strict mode still rejects ::1."""
|
||||
os.environ["MODELENDPOINT_BLOCK_PRIVATE_IPS"] = "true"
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "http://[::1]:11434/v1"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
finally:
|
||||
os.environ.pop("MODELENDPOINT_BLOCK_PRIVATE_IPS", None)
|
||||
|
||||
|
||||
class TestModelEndpointCreateSSRF:
|
||||
"""SSRF protection for POST /model-endpoints."""
|
||||
|
||||
def test_cloud_metadata_blocked(self, client):
|
||||
"""Cloud metadata IP must be rejected on endpoint creation."""
|
||||
resp = client.post(
|
||||
"/api/model-endpoints",
|
||||
data={"base_url": "http://169.254.169.254/latest/meta-data/"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
|
||||
def test_non_http_scheme_blocked(self, client):
|
||||
"""Non-HTTP schemes must be rejected on endpoint creation."""
|
||||
resp = client.post(
|
||||
"/api/model-endpoints",
|
||||
data={"base_url": "gopher://evil:6379/secret"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
|
||||
def test_loopback_accepted_by_default(self, client):
|
||||
"""Local-first: loopback is accepted when block_private=False."""
|
||||
with patch("src.url_safety._default_resolver", LOCALHOST_V4):
|
||||
resp = client.post(
|
||||
"/api/model-endpoints",
|
||||
data={"base_url": "http://localhost:11434/v1"},
|
||||
)
|
||||
# Guard passes; route may 500 on DB/probe but never 400 SSRF.
|
||||
assert resp.status_code != 400
|
||||
body = resp.text if resp.status_code == 500 else str(resp.json())
|
||||
assert "Rejected endpoint URL" not in body
|
||||
|
||||
def test_loopback_rejected_in_strict_mode(self, client):
|
||||
"""Strict mode: loopback is rejected when block_private=True."""
|
||||
os.environ["MODELENDPOINT_BLOCK_PRIVATE_IPS"] = "true"
|
||||
try:
|
||||
with patch("src.url_safety._default_resolver", LOCALHOST_V4):
|
||||
resp = client.post(
|
||||
"/api/model-endpoints",
|
||||
data={"base_url": "http://localhost:11434/v1"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
finally:
|
||||
os.environ.pop("MODELENDPOINT_BLOCK_PRIVATE_IPS", None)
|
||||
|
||||
|
||||
class TestModelEndpointPatchSSRF:
|
||||
"""SSRF protection for PATCH /api/model-endpoints/{ep_id} (base_url).
|
||||
|
||||
The update path rewrites the stored base_url, which later fetches probe,
|
||||
list models and serve chat through — it must pass the same guard as the
|
||||
POST routes instead of bypassing it.
|
||||
"""
|
||||
|
||||
def _install_fake_endpoint(self, monkeypatch, base_url="http://localhost:9999/v1"):
|
||||
import routes.model_routes as mr
|
||||
|
||||
ep = SimpleNamespace(
|
||||
id="ep1",
|
||||
name="EP",
|
||||
base_url=base_url,
|
||||
api_key=None,
|
||||
is_enabled=True,
|
||||
owner=None,
|
||||
hidden_models=None,
|
||||
cached_models=None,
|
||||
pinned_models=None,
|
||||
model_type="llm",
|
||||
supports_tools=None,
|
||||
endpoint_kind="auto",
|
||||
model_refresh_mode="auto",
|
||||
model_refresh_interval=None,
|
||||
model_refresh_timeout=None,
|
||||
created_at=None,
|
||||
updated_at=None,
|
||||
)
|
||||
|
||||
class _FakeDb:
|
||||
def query(self, _model):
|
||||
return self
|
||||
|
||||
def filter(self, *_args, **_kwargs):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return ep
|
||||
|
||||
def commit(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(mr, "SessionLocal", lambda: _FakeDb())
|
||||
monkeypatch.setattr(mr, "require_admin", lambda request: None)
|
||||
monkeypatch.setattr(mr, "_chatgpt_endpoint_visible", lambda ep_, request: True)
|
||||
return ep
|
||||
|
||||
def test_patch_cloud_metadata_blocked(self, client, monkeypatch):
|
||||
"""PATCH must reject the cloud metadata URL, not store it."""
|
||||
ep = self._install_fake_endpoint(monkeypatch)
|
||||
resp = client.patch(
|
||||
"/api/model-endpoints/ep1",
|
||||
json={"base_url": "http://169.254.169.254/latest/meta-data"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected endpoint URL" in resp.json()["detail"]
|
||||
assert ep.base_url == "http://localhost:9999/v1"
|
||||
|
||||
def test_patch_non_http_scheme_blocked(self, client, monkeypatch):
|
||||
"""PATCH must reject non-HTTP schemes."""
|
||||
ep = self._install_fake_endpoint(monkeypatch)
|
||||
resp = client.patch(
|
||||
"/api/model-endpoints/ep1",
|
||||
json={"base_url": "file:///etc/passwd"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected endpoint URL" in resp.json()["detail"]
|
||||
assert ep.base_url == "http://localhost:9999/v1"
|
||||
|
||||
def test_patch_valid_url_accepted(self, client, monkeypatch):
|
||||
"""Control: a legitimate local endpoint URL still updates fine."""
|
||||
ep = self._install_fake_endpoint(monkeypatch)
|
||||
resp = client.patch(
|
||||
"/api/model-endpoints/ep1",
|
||||
json={"base_url": "http://127.0.0.1:11434/v1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert ep.base_url == "http://127.0.0.1:11434/v1"
|
||||
@@ -1281,6 +1281,7 @@ def _patch_create_deps(monkeypatch, db, settings=None):
|
||||
monkeypatch.setattr(model_routes, "_save_settings", lambda s: settings.update(s))
|
||||
monkeypatch.setattr(endpoint_resolver, "resolve_url", lambda u: u)
|
||||
monkeypatch.setattr(auth_helpers, "get_current_user", lambda req: None)
|
||||
monkeypatch.setattr(model_routes, "check_outbound_url", lambda *a, **k: (True, "ok"))
|
||||
return settings
|
||||
|
||||
|
||||
@@ -2000,6 +2001,7 @@ def test_explicit_proxy_test_fetches_models_with_long_timeout(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||
monkeypatch.setattr(model_routes, "_ping_endpoint", lambda *a, **k: (_ for _ in ()).throw(AssertionError("ping should not run when model listing succeeds")))
|
||||
monkeypatch.setattr(model_routes, "check_outbound_url", lambda *a, **k: (True, "ok"))
|
||||
|
||||
calls = []
|
||||
returned = ["NVIDIA NIM/openai/gpt-oss-120b", "mistral/mistral-small-2603"]
|
||||
@@ -2038,6 +2040,7 @@ def test_explicit_proxy_add_fetches_and_caches_models_with_long_timeout(monkeypa
|
||||
monkeypatch.setattr(model_routes, "_save_settings", lambda settings: None)
|
||||
monkeypatch.setattr("src.auth_helpers.get_current_user", lambda request: None)
|
||||
monkeypatch.setattr(model_routes, "_ping_endpoint", lambda *a, **k: (_ for _ in ()).throw(AssertionError("ping should not run when model listing succeeds")))
|
||||
monkeypatch.setattr(model_routes, "check_outbound_url", lambda *a, **k: (True, "ok"))
|
||||
|
||||
calls = []
|
||||
returned = ["NVIDIA NIM/openai/gpt-oss-120b", "mistral/mistral-small-2603"]
|
||||
|
||||
@@ -117,7 +117,8 @@ async def test_remote_approval_is_bound_to_operation_and_request(manager, change
|
||||
content = '{"record":"two"}'
|
||||
else:
|
||||
authority = replace(authority, **{"request": {"request_id": "other"}, "owner": {"owner": "bob"},
|
||||
"session": {"session_id": "other"}}[change])
|
||||
"session": {"session_id": "other"}}[change],
|
||||
resource_roots=None) # re-seal the root for the changed owner
|
||||
_, result = await dispatch(authority, tool, content, exact_approval=exact,
|
||||
security_context=ToolRunSecurityContext(external_untrusted_context_seen=True))
|
||||
assert result["exit_code"] == 1
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Guarded resolution of scheduled-research settings (#5049).
|
||||
|
||||
The scheduler reads ``research_max_tokens`` /
|
||||
``research_extraction_timeout_seconds`` / ``research_extraction_concurrency``
|
||||
from settings.json with bare ``int()`` casts. A hand-edited value like
|
||||
``{"research_max_tokens": "invalid"}`` used to raise inside
|
||||
``TaskScheduler._execute_research_task`` and kill every scheduled research
|
||||
run until settings.json was fixed by hand.
|
||||
|
||||
``_resolve_research_settings`` must fall back to the documented defaults
|
||||
(8192 / 90 / 3) for non-numeric input while preserving valid-value behavior
|
||||
(numeric strings still parse). ``src/research_handler.py`` already guards the
|
||||
same reads defensively; this is the scheduler's equivalent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from src.task_scheduler import _resolve_research_settings
|
||||
|
||||
|
||||
def _fake_get_setting(values):
|
||||
"""A get_setting stand-in backed by a plain dict."""
|
||||
|
||||
def get(key, default=None):
|
||||
return values.get(key, default)
|
||||
|
||||
return get
|
||||
|
||||
|
||||
def test_non_numeric_values_fall_back_to_documented_defaults():
|
||||
get = _fake_get_setting(
|
||||
{
|
||||
"research_max_tokens": "invalid",
|
||||
"research_extraction_timeout_seconds": "bogus",
|
||||
"research_extraction_concurrency": "not-a-number",
|
||||
}
|
||||
)
|
||||
|
||||
assert _resolve_research_settings(get) == (8192, 90, 3)
|
||||
|
||||
|
||||
def test_none_values_fall_back_to_documented_defaults():
|
||||
get = _fake_get_setting(
|
||||
{
|
||||
"research_max_tokens": None,
|
||||
"research_extraction_timeout_seconds": None,
|
||||
"research_extraction_concurrency": None,
|
||||
}
|
||||
)
|
||||
|
||||
assert _resolve_research_settings(get) == (8192, 90, 3)
|
||||
|
||||
|
||||
def test_numeric_strings_still_parse():
|
||||
get = _fake_get_setting(
|
||||
{
|
||||
"research_max_tokens": "4096",
|
||||
"research_extraction_timeout_seconds": "45",
|
||||
"research_extraction_concurrency": "2",
|
||||
}
|
||||
)
|
||||
|
||||
assert _resolve_research_settings(get) == (4096, 45, 2)
|
||||
|
||||
|
||||
def test_missing_keys_keep_documented_defaults():
|
||||
assert _resolve_research_settings(_fake_get_setting({})) == (8192, 90, 3)
|
||||
|
||||
|
||||
def test_valid_ints_pass_through():
|
||||
get = _fake_get_setting(
|
||||
{
|
||||
"research_max_tokens": 2048,
|
||||
"research_extraction_timeout_seconds": 120,
|
||||
"research_extraction_concurrency": 6,
|
||||
}
|
||||
)
|
||||
|
||||
assert _resolve_research_settings(get) == (2048, 120, 6)
|
||||
|
||||
|
||||
def test_non_finite_json_numbers_fall_back_individually():
|
||||
"""JSON exponent overflow produces infinity, which int() cannot convert."""
|
||||
keys = (
|
||||
"research_max_tokens",
|
||||
"research_extraction_timeout_seconds",
|
||||
"research_extraction_concurrency",
|
||||
)
|
||||
valid = (4096, 45, 2)
|
||||
defaults = (8192, 90, 3)
|
||||
|
||||
for non_finite in (json.loads("1e309"), json.loads("-1e309")):
|
||||
for index, key in enumerate(keys):
|
||||
values = dict(zip(keys, valid))
|
||||
values[key] = non_finite
|
||||
expected = list(valid)
|
||||
expected[index] = defaults[index]
|
||||
assert _resolve_research_settings(
|
||||
_fake_get_setting(values)
|
||||
) == tuple(expected)
|
||||
@@ -0,0 +1,106 @@
|
||||
"""A network failure must not be mistaken for rejection of an incident chain."""
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
INCIDENT = json.loads((ROOT / "tests/fixtures/ca-trust/incident.json").read_text())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def validator():
|
||||
checker = ModuleType("check_ca_trust")
|
||||
checker.check_bundle = lambda path: None
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"searxng_validation", ROOT / "docker/searxng/validate_image.py"
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
with patch.dict(sys.modules, {"check_ca_trust": checker}):
|
||||
spec.loader.exec_module(module)
|
||||
module.__file__ = str(ROOT / "tests/fixtures/ca-trust/validate_image.py")
|
||||
return module
|
||||
|
||||
|
||||
class RequestFailure(Exception):
|
||||
def __init__(self, code):
|
||||
super().__init__(f"curl error {code}")
|
||||
self.code = code
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", [60, 6, 28, 35, None])
|
||||
def test_live_probe_requires_certificate_errors_for_every_host(validator, code):
|
||||
hosts = []
|
||||
|
||||
async def get(url, **kwargs):
|
||||
if url == "https://pypi.org/simple/certifi/":
|
||||
return SimpleNamespace(status_code=200)
|
||||
hosts.append(url.removeprefix("https://").removesuffix("/"))
|
||||
assert kwargs["allow_redirects"] is False
|
||||
if code is not None:
|
||||
raise RequestFailure(code)
|
||||
return SimpleNamespace(status_code=200)
|
||||
|
||||
client = SimpleNamespace(get=get, aclose=AsyncMock())
|
||||
factory = ModuleType("searx.network.client")
|
||||
|
||||
def new_client(**kwargs):
|
||||
assert kwargs["verify"] is True
|
||||
return client
|
||||
|
||||
factory.new_client = new_client
|
||||
errors = ModuleType("curl_cffi.requests.exceptions")
|
||||
errors.RequestException = RequestFailure
|
||||
modules = {"searx.network.client": factory, "curl_cffi.requests.exceptions": errors}
|
||||
with patch.dict(sys.modules, modules), patch.object(sys, "path", list(sys.path)):
|
||||
if code == 60:
|
||||
asyncio.run(validator.check_https())
|
||||
assert set(hosts) == set(INCIDENT["chains"])
|
||||
else:
|
||||
expected = "unexpectedly accepted" if code is None else "inconclusive"
|
||||
with pytest.raises(RuntimeError, match=expected):
|
||||
asyncio.run(validator.check_https())
|
||||
client.aclose.assert_awaited_once()
|
||||
|
||||
|
||||
def test_offline_probe_rejects_openssl_execution_failure(validator):
|
||||
result = SimpleNamespace(returncode=1, stderr="openssl: unknown option", stdout="")
|
||||
with patch.dict(validator.os.environ, {"SSL_CERT_FILE": "/unused.pem", "SSL_CERT_DIR": "/unused"}), \
|
||||
patch.object(validator.subprocess, "run", return_value=result):
|
||||
with pytest.raises(RuntimeError, match="not a certificate trust rejection"):
|
||||
validator.check_chains()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("results", [[{"title": "ordinary result"}], []])
|
||||
def test_running_probe_checks_existing_service_without_repair_or_second_boot(validator, tmp_path, results):
|
||||
certificate = tmp_path / "01234567.0"
|
||||
certificate.write_text("certificate double")
|
||||
(tmp_path / "unrelated").write_text("ignored")
|
||||
health = io.BytesIO(b"OK")
|
||||
health.status = 200
|
||||
search = io.BytesIO(json.dumps({"results": results}).encode())
|
||||
with patch.dict(validator.os.environ, {"SSL_CERT_DIR": str(tmp_path), "GRANIAN_PORT": "8080"}), \
|
||||
patch.object(validator.urllib.request, "build_opener") as opener, \
|
||||
patch.object(validator, "check_chains") as chains, \
|
||||
patch.object(validator, "check_bundle") as bundle, \
|
||||
patch.object(validator, "check_https", new_callable=AsyncMock) as https, \
|
||||
patch.object(validator.subprocess, "Popen") as boot:
|
||||
opener.return_value.open.side_effect = [health, search]
|
||||
if results:
|
||||
validator.check_running(live=True)
|
||||
else:
|
||||
with pytest.raises(RuntimeError, match="no results"):
|
||||
validator.check_running(live=True)
|
||||
assert opener.return_value.open.call_args_list[0].args == ("http://127.0.0.1:8080/healthz",)
|
||||
chains.assert_called_once_with()
|
||||
bundle.assert_called_once_with(certificate)
|
||||
https.assert_awaited_once()
|
||||
boot.assert_not_called()
|
||||
@@ -10,17 +10,22 @@ startup. This guards that the pin stays in place.
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
COMPOSE = Path(__file__).resolve().parent.parent / "docker-compose.yml"
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
COMPOSE = ROOT / "docker-compose.yml"
|
||||
|
||||
|
||||
def test_searxng_image_is_pinned_not_latest():
|
||||
text = COMPOSE.read_text(encoding="utf-8")
|
||||
m = re.search(r"image:\s*\S*searxng/searxng:(\S+)", text)
|
||||
assert m, "searxng image line not found in docker-compose.yml"
|
||||
tag = m.group(1)
|
||||
assert tag != "latest", (
|
||||
"SearXNG must be pinned, not ':latest' — odysseus startup depends on its "
|
||||
"healthcheck, so a broken upstream latest tag blocks the app (issue #1414)"
|
||||
)
|
||||
# A real version tag (date-based, e.g. 2026.5.31-7159b8aed), not a moving ref.
|
||||
assert re.match(r"\d{4}\.\d", tag), f"expected a versioned tag, got {tag!r}"
|
||||
image = yaml.safe_load(text)["services"]["searxng"]["image"]
|
||||
assert re.fullmatch(
|
||||
r"docker\.io/searxng/searxng:\d{4}\.\d[^\s@]*@sha256:[0-9a-f]{64}", image
|
||||
), f"SearXNG must use a versioned immutable upstream image: {image!r}"
|
||||
|
||||
|
||||
def test_all_standalone_compose_files_select_the_same_searxng_image():
|
||||
expected = yaml.safe_load(COMPOSE.read_text())["services"]["searxng"]["image"]
|
||||
for name in ("docker-compose.gpu-amd.yml", "docker-compose.gpu-nvidia.yml"):
|
||||
actual = yaml.safe_load((ROOT / name).read_text())["services"]["searxng"]["image"]
|
||||
assert actual == expected, f"{name} selects a different SearXNG image"
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Native checks must validate the selected runtime without registry credentials."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_ci_checks_the_pinned_upstream_image_on_both_native_architectures():
|
||||
workflow = yaml.safe_load((ROOT / ".github/workflows/searxng-image.yml").read_text())
|
||||
events = workflow.get("on", workflow.get(True))
|
||||
assert "pull_request_target" not in events
|
||||
assert workflow["permissions"] == {"contents": "read"}
|
||||
assert set(workflow["jobs"]) == {"validate"}
|
||||
job = workflow["jobs"]["validate"]
|
||||
matrix = job["strategy"]["matrix"]["include"]
|
||||
assert {(row["arch"], row["runner"]) for row in matrix} == {
|
||||
("amd64", "ubuntu-latest"), ("arm64", "ubuntu-24.04-arm")
|
||||
}
|
||||
steps = job["steps"]
|
||||
assert not any("login-action" in step.get("uses", "") for step in steps)
|
||||
assert all(step.get("with", {}).get("persist-credentials") is False
|
||||
for step in steps if "actions/checkout@" in step.get("uses", ""))
|
||||
offline = next(step for step in steps if "docker/build-push-action@" in step.get("uses", ""))
|
||||
assert offline["with"]["network"] == "none"
|
||||
assert offline["with"]["build-args"] == "SEARXNG_IMAGE=${{ steps.image.outputs.image }}"
|
||||
live = next(step for step in steps if "validate-searxng-image.sh" in step.get("run", ""))
|
||||
assert live["env"]["SEARXNG_TEST_IMAGE"] == "${{ steps.image.outputs.image }}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure,expected", [("none", 0), ("exec", 7), ("up", 1), ("existing-volume", 1)])
|
||||
def test_compose_probe_owns_only_its_temporary_resources(tmp_path, failure, expected):
|
||||
"""Exercise the real helper with a Docker double, including failure cleanup."""
|
||||
binary = tmp_path / "docker"
|
||||
calls = tmp_path / "calls.jsonl"
|
||||
binary.write_text('''#!/usr/bin/env python3
|
||||
import json, os, sys
|
||||
args = sys.argv[1:]
|
||||
with open(os.environ["DOCKER_CALLS"], "a") as log:
|
||||
log.write(json.dumps({"args": args, "volume": os.environ["SEARXNG_VALIDATION_VOLUME"], "secret": os.environ["SEARXNG_SECRET"]}) + "\\n")
|
||||
failure = os.environ["DOCKER_FAILURE"]
|
||||
if args[:2] == ["volume", "inspect"]:
|
||||
sys.exit(0 if failure == "existing-volume" else 1)
|
||||
if "exec" in args and failure == "exec":
|
||||
sys.exit(7)
|
||||
if "up" in args and failure == "up":
|
||||
sys.exit(9)
|
||||
''')
|
||||
binary.chmod(0o755)
|
||||
result = subprocess.run(["bash", str(ROOT / ".github/scripts/validate-searxng-image.sh"), "localhost/searxng:validated"], env={**os.environ, "PATH": str(tmp_path) + ":" + os.environ["PATH"], "DOCKER_CALLS": str(calls), "DOCKER_FAILURE": failure, "SEARXNG_SECRET": "operator-secret-must-not-propagate"}, capture_output=True, text=True)
|
||||
assert result.returncode == expected, result.stderr
|
||||
entries = [json.loads(line) for line in calls.read_text().splitlines()]
|
||||
assert all(entry["secret"] == "searxng-ci-validation-only" for entry in entries)
|
||||
commands = [entry["args"] for entry in entries]
|
||||
if failure == "existing-volume":
|
||||
assert len(commands) == 1
|
||||
return
|
||||
up = next(command for command in commands if "up" in command)
|
||||
assert up[up.index("--env-file") + 1] == "/dev/null"
|
||||
assert "--no-deps" in up and "--no-build" in up and "--wait" in up
|
||||
assert up[up.index("--pull") + 1] == "never"
|
||||
down = next(command for command in commands if "down" in command)
|
||||
assert "--volumes" not in down
|
||||
assert commands[-1] == ["volume", "rm", entries[-1]["volume"]]
|
||||
if failure != "up":
|
||||
execute = next(command for command in commands if "exec" in command)
|
||||
assert execute[-2:] == ["--running", "--live"]
|
||||
@@ -255,7 +255,8 @@ async def test_read_file_dispatch_blocks_etc_shadow(monkeypatch):
|
||||
security_context=NO_TOOL_SECURITY_CONTEXT,
|
||||
)
|
||||
assert result.get("failure_kind") == "resource_identity_denied"
|
||||
assert "sealed resource root" in (result.get("error") or "")
|
||||
assert "outside the sealed roots" in (result.get("error") or "")
|
||||
assert result.get("blocked") is True
|
||||
assert result.get("exit_code") == 1
|
||||
|
||||
|
||||
@@ -285,7 +286,8 @@ async def test_write_file_dispatch_blocks_authorized_keys(monkeypatch):
|
||||
security_context=NO_TOOL_SECURITY_CONTEXT,
|
||||
)
|
||||
assert result.get("failure_kind") == "resource_identity_denied"
|
||||
assert "sealed resource root" in (result.get("error") or "")
|
||||
assert "outside the sealed roots" in (result.get("error") or "")
|
||||
assert result.get("blocked") is True
|
||||
assert result.get("exit_code") == 1
|
||||
|
||||
|
||||
@@ -349,7 +351,8 @@ async def test_write_file_dispatch_blocks_cron(monkeypatch):
|
||||
security_context=NO_TOOL_SECURITY_CONTEXT,
|
||||
)
|
||||
assert result.get("failure_kind") == "resource_identity_denied"
|
||||
assert "sealed resource root" in (result.get("error") or "")
|
||||
assert "outside the sealed roots" in (result.get("error") or "")
|
||||
assert result.get("blocked") is True
|
||||
assert result.get("exit_code") == 1
|
||||
@pytest.mark.parametrize("filename", ["auth.json", "app.db", "settings.json"])
|
||||
def test_application_secrets_are_sensitive_paths(filename):
|
||||
|
||||
Reference in New Issue
Block a user