diff --git a/src/Web/StellaOps.Web/output/playwright/live-setup-wizard-first-run-bootstrap.auth.json b/src/Web/StellaOps.Web/output/playwright/live-setup-wizard-first-run-bootstrap.auth.json new file mode 100644 index 000000000..23d640763 --- /dev/null +++ b/src/Web/StellaOps.Web/output/playwright/live-setup-wizard-first-run-bootstrap.auth.json @@ -0,0 +1,63 @@ +{ + "authenticatedAtUtc": "2026-04-19T11:21:13.948Z", + "authenticated": true, + "error": null, + "baseUrl": "https://stella-ops.local", + "finalUrl": "https://stella-ops.local/?tenant=default®ions=apac,eu-west,us-east", + "title": "Dashboard - StellaOps", + "bodyText": "Skip to main content\nStella Ops\nv1.0.0-alpha\nDashboard\nDaily health, feed freshness, and onboarding progress\nRELEASE CONTROL\n\nPlan, approve, and promote verified releases through your environments.\n\nEnvironments\nReadiness, gate status, and promotion topology\nDeployments\nActive deployments and approval queue\n4\nReleases\nRelease versions and bundles\n1\nRelease Policies\nPolicy packs, governance, VEX, and simulation\nSECURITY\n\nScan images, triage findings, and explain exploitability before promotion.\n\nImage Security\nSecurity posture, findings, SBOM, and evidence for container images\nTriage Queue\nPrioritized vulnerability triage work queue\nRisk Overview\nFleet-wide risk budget and compliance posture\nAdvisory Sources\nFeed health, freshness, and SLA compliance\nOPERATIONS\n\nRun the platform, keep feeds healthy, and investigate background execution.\n\nScheduled Jobs\nScheduled scans, runs, and worker fleet\nFeeds & Airgap\nFeed freshness, offline kits, and transfer readiness\nScripts\nOperator scripts and reusable automation entry points\nSTART HERE\nDiagnostics\nService health, drift signals, and operational checks\nAudit\nSETTINGS\n\nIdentity, trust, tenant settings, and governance controls for operators.\n\nIntegrations\nConnect source control, registries, notifications, and delivery systems\nIdentity & Access\nManage sign-in, access rules, and operator scopes\nCertificates & Trust\nTheme & Branding\nUser Preferences\nPersonal defaults for helper behavior, theme, and working context\nTENANT\nDefault Tenant\nREGION\nNone\nENV\nNone\n1h\n6h\n24h\n7d\n30d\nSTAGE\nAll\nf945f00811f44f008058268a264ed015\nDashboard\n?\nABOUT THIS PAGE\nDashboard\n\nThis is your daily command center. It tells you which environments are healthy, where risk is building up, whether advisory feeds are current, and what action should happen next.\n\nKey concepts\nSBOM and reachability\n\nSBOM tells Stella what is inside each image. Reachability tells Stella which of those vulnerable code paths are actually callable, so you can separate real risk from background noise.\n\nSeverity counts\n\nCritical means fix now. High means near-term remediation. Medium and Low help you plan backlog work without losing sight of immediate blockers.\n\nNext-step workflow\n\nFresh installations usually follow the same order: run diagnostics, connect integrations, scan an image, review findings, then create and promote a release.\n\nCommon actions\nScan your first image\nCheck feed status\nRun Diagnostics\nDocs\nOperator guide\nArchitecture overview\nDashboard\n\nDefault Tenant\n\nQUICK LINKS:\nDeployments\nDeployment timeline and run history\nSecurity & Risk\nPosture, findings, and reachability\nOperations\nPlatform health and execution control\nEvidence\nDecision capsules and audit trail\nPlatform Setup\nEnvironments, integrations, topology\nDiagnostics\nRun health checks on your deployment\n\nFIRST VISIT\n\nWelcome to Stella Ops\n\nThis dashboard is your daily command center. It summarizes SBOM coverage, reachable risk, feed freshness, and environment health so you can decide what to fix, approve, or investigate next.\n\nStart setup wizard\nRun diagnostics\nSeverity guide\nCRITICAL Exploitable or release-blocking. Fix immediately.\nHIGH Serious exposure. Schedule remediation within days.\nMEDIUM Moderate risk. Address in planned sprint work.\nLOW Track and fix when it is cost-effective.\nWhat should I do next?\n\nThese suggestions are based on the current state of your environments, feeds, and findings.\n\nGenerate your first SBOM\nEvery environment currently shows SBOM missing. Scan one container image to unlock posture, reachability, and evidence data.\n1 critical findings need triage\nOpen the findings workflow to decide whether each critical issue should be fixed, waived, or explained with VEX evidence.\nResolve unknown environment health\nUnknown health usually means no agent, signal probe, or readiness telemetry is reporting yet. Run diagnostics to find the missing dependency.\nCheck advisory feed freshness\nYour advisory sources are missing, stale, or degraded. Refresh them so new CVEs and VEX updates reach the dashboard.\nHide\nPENDING ACTIONS\nAll environments\nUS Production\nUs East\nUNKNOWN\nSBOM\nmissing\nCRITR\n0\nHIGHR\n0\nB/I/R\n0/0\nPENDING\n0\nNo deployments\nDetail\nFindings\nUS UAT\nUs East\nUNKNOWN\nSBOM\nmissing\nCRITR\n0\nHIGHR\n0\nB/I/R\n0/0\nPENDING\n0\nNo deployments\nDetail\nFindings\nEU Production\nEu West\nUNKNOWN\nSBOM\nmissing\nCRITR\n0\nHIGHR\n0\nB/I/R\n0/0\nPENDING\n0\nNo deployments\nDetail\nFindings\nEU Staging\nEu West\nUNKNOWN\nSBOM\nmissing\nCRITR\n0\nHIGHR\n0\nB/I/R\n0/0\nPENDING\n0\nNo deployments\nDetail\nFindings\nAPAC Production\nAPAC\nUNKNOWN\nSBOM\nmissing\nCRITR\n0\nHIGHR\n0\nB/I/R\n0/0\nPENDING\n0\nNo deployments\nDetail\nFindings\nCritical Open\nCritical vulnerabilities needing triage\n1\nVULNERABILITY SUMMARY ?\nCritical\n3\nHigh\n2\nMedium\n1\nLow\n0\n6 total\n1 critical open\nView Findings\nFEED STATUS ?\n1\nactive\nNot checked\nManage Sources\nSBOM HEALTH ?\nCRITICAL ENVS\n0\nWith critical issues\nCRIT. REACHABLE\n0\nReachable criticals\nCLEAN ENVS\n5\nNo critical findings\nView SBOM\nENVIRONMENT HEALTH\nENVIRONMENTS\n5\nRegistered targets\nBLOCKED\n0\nBlocking releases\nDEGRADED\n0\nNeeding attention\nHEALTHY\n0\nFully operational\nENVIRONMENTS AT RISK\nOpen all\nREGION/ENV\tHEALTH\tSBOM\tCRITR\tACTION\nUs East / US Production\tunknown\tmissing\t0\tOpen\nUs East / US UAT\tunknown\tmissing\t0\tOpen\nEu West / EU Production\tunknown\tmissing\t0\tOpen\nEu West / EU Staging\tunknown\tmissing\t0\tOpen\nAPAC / APAC Production\tunknown\tmissing\t0\tOpen\nServices\nFeeds\nSecurity\nEvidence\nDLQ\nDiagnostics\nCritical findings need triage\nCritical open findings usually mean exploitable issues that can block promotions or require compensating evidence. Triage them first, then decide whether to fix, create an exception, or attach VEX justification.\nView findings\n1 / 14", + "cookies": [], + "storage": { + "localStorageEntries": [ + [ + "stellaops.auth.session.full", + "{\"tokens\":{\"accessToken\":\"eyJhbGciOiJSUzI1NiIsImtpZCI6IjdLUks4OUw1UTNESkVQR09IV1NPSDNMVDktTVdNWVVJRS1aSU1VX1EiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2F1dGhvcml0eS5zdGVsbGEtb3BzLmxvY2FsLyIsImV4cCI6MTc3NjU5OTQ3MiwiaWF0IjoxNzc2NTk3NjcyLCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIG9mZmxpbmVfYWNjZXNzIHVpLnJlYWQgdWkuYWRtaW4gdWkucHJlZmVyZW5jZXMucmVhZCB1aS5wcmVmZXJlbmNlcy53cml0ZSBhdXRob3JpdHk6dGVuYW50cy5yZWFkIGF1dGhvcml0eTp0ZW5hbnRzLndyaXRlIGF1dGhvcml0eTp1c2Vycy5yZWFkIGF1dGhvcml0eTp1c2Vycy53cml0ZSBhdXRob3JpdHk6cm9sZXMucmVhZCBhdXRob3JpdHk6cm9sZXMud3JpdGUgYXV0aG9yaXR5OmNsaWVudHMucmVhZCBhdXRob3JpdHk6Y2xpZW50cy53cml0ZSBhdXRob3JpdHk6dG9rZW5zLnJlYWQgYXV0aG9yaXR5OnRva2Vucy5yZXZva2UgYXV0aG9yaXR5OmJyYW5kaW5nLnJlYWQgYXV0aG9yaXR5OmJyYW5kaW5nLndyaXRlIGF1dGhvcml0eS5hdWRpdC5yZWFkIGdyYXBoOnJlYWQgc2JvbTpyZWFkIHNjYW5uZXI6cmVhZCBwb2xpY3k6cmVhZCBwb2xpY3k6c2ltdWxhdGUgcG9saWN5OmF1dGhvciBwb2xpY3k6cmV2aWV3IHBvbGljeTphcHByb3ZlIHBvbGljeTpydW4gcG9saWN5OmFjdGl2YXRlIHBvbGljeTphdWRpdCBwb2xpY3k6ZWRpdCBwb2xpY3k6b3BlcmF0ZSBwb2xpY3k6cHVibGlzaCBhaXJnYXA6c2VhbCBhaXJnYXA6c3RhdHVzOnJlYWQgb3JjaDpyZWFkIG9yY2g6b3BlcmF0ZSBvcmNoOnF1b3RhIGFuYWx5dGljcy5yZWFkIGFkdmlzb3J5OnJlYWQgYWR2aXNvcnktYWk6dmlldyBhZHZpc29yeS1haTpvcGVyYXRlIHZleDpyZWFkIHZleGh1YjpyZWFkIGV4Y2VwdGlvbnM6cmVhZCBleGNlcHRpb25zOmFwcHJvdmUgYW9jOnZlcmlmeSBmaW5kaW5nczpyZWFkIHJlbGVhc2U6cmVhZCByZWxlYXNlOndyaXRlIHJlbGVhc2U6cHVibGlzaCBzY2hlZHVsZXI6cmVhZCBzY2hlZHVsZXI6b3BlcmF0ZSBub3RpZnkudmlld2VyIG5vdGlmeS5vcGVyYXRvciBub3RpZnkuYWRtaW4gbm90aWZ5LmVzY2FsYXRlIGV2aWRlbmNlOnJlYWQgZXhwb3J0LnZpZXdlciBleHBvcnQub3BlcmF0b3IgZXhwb3J0LmFkbWluIHZ1bG46dmlldyB2dWxuOmludmVzdGlnYXRlIHZ1bG46b3BlcmF0ZSB2dWxuOmF1ZGl0IHBsYXRmb3JtLmNvbnRleHQucmVhZCBwbGF0Zm9ybS5jb250ZXh0LndyaXRlIGRvY3RvcjpydW4gZG9jdG9yOmFkbWluIG9wcy5oZWFsdGggaW50ZWdyYXRpb246cmVhZCBpbnRlZ3JhdGlvbjp3cml0ZSBpbnRlZ3JhdGlvbjpvcGVyYXRlIHBhY2tzLnJlYWQgcGFja3Mud3JpdGUgcGFja3MucnVuIHBhY2tzLmFwcHJvdmUgcmVnaXN0cnkuYWRtaW4gdGltZWxpbmU6cmVhZCB0aW1lbGluZTp3cml0ZSB0cnVzdDpyZWFkIHRydXN0OndyaXRlIHRydXN0OmFkbWluIHNpZ25lcjpyZWFkIHNpZ25lcjpzaWduIHNpZ25lcjpyb3RhdGUgc2lnbmVyOmFkbWluIiwianRpIjoiOWJhYWRkZmUtOGVhYi00YjdlLWIxNDAtYjdkYmM2ZGY0NDY1Iiwic3ViIjoiZjk0NWYwMDgxMWY0NGYwMDgwNTgyNjhhMjY0ZWQwMTUiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOiJhZG1pbiIsInN0ZWxsYW9wczp0ZW5hbnQiOiJkZWZhdWx0IiwiYXV0aF90aW1lIjoxNzc2NTk3NjcxLCJvaV9wcnN0Ijoic3RlbGxhLW9wcy11aSIsImNsaWVudF9pZCI6InN0ZWxsYS1vcHMtdWkifQ.LTsaW7eUNYBfwjxklmhF24qfW9BP1Jco03vZppZeVJyToGF0mT0_Sr1vdfa_spRIlPUVYjDrRHhAIMuAQak350DXzJqF9nY4kvhQzW07loyB5ASCdt1IBijRqjXVp8nGVVP5_pGZiLbX34AxRRbfrLE7KwRFkf0NZaochfBhRjqoLN2uRw-IIGkJMNthFae8qPmNvFvDaUruygUDIXv2yUVS_NnXXYJDOy9BC35akAlEqsEG-MGSomuUbo-YeS4hjpBG7mF7LX4UiUYFkePmRWCKVDJDPYExPp3j3n0cMQaqQci1OiKvbX3jJYCM1wkMXSV28ZeRYC9ZjNZVJgG76Q\",\"tokenType\":\"Bearer\",\"refreshToken\":\"eyJhbGciOiJSU0EtT0FFUCIsImVuYyI6IkEyNTZDQkMtSFM1MTIiLCJraWQiOiJZV0pPRVlCSzU3UEpSQTFTSFhFWkM3NEFRQktRWFdEQjZaWlVPV0dVIiwidHlwIjoib2lfcmVmdCtqd3QiLCJjdHkiOiJKV1QifQ.yCZRh1VwHde8Mw6susd190XjLjX4CYt4XMW_es2vU1YFnLm1gOjya3tVSPjNnzOW57EWn_0q4nTSocTNJFV8JDGfR5S92xRbvCBkV54MKWgILqsoRY04eU1Q1fszPybTZqTxj-rX-IMiAJ0EXqxtTLJdxMFN5sdQYcKOMdZV6S6dBArSEMdfdVQKCtuEAKdUEpILsOcI4S4x3B8z8tlw5DsCdiFgW3M2mijFrKppiPGiKDnN1upeZ6StoaUu-Ijnw_hZIo2vbBXINAe-5p_naJ-6JxAGP_UlCPNtWvIZuIA6qV-kldCDE8kWARn1rnXCqB5Zjg3wwulVjlh5u7__0w.snJwpNse2FWAMfsSA08jSg.4Lngki9KU2RPD0KkchsxkAtAaITJR0Hv_gvL4Rfn3-UOif2kvIGk3iX8G3Xo0gvdem7x0_70EMM0jLgZT-tvxc5gm10lbuD3R1nCefcgJcAD3zBN-UKKbNRVe8VxTWNNQXhK8bAEvphjgR3yhQt6LyxQw2-arjkz947kRPctj9UmRQNItD4oN0KOchEmuGIYhCzi3SKsJxbcembWNPmFU9DKJqFdRotzCy8bMlYBY2Wh744Ue3AhuJeg1YuYd6giQZNowlkuP10O27FwATpfC4iE6Y1rbMFj8ZIXMJn5vwz1gh51n4NCp-qjQOeYCcECV4E3ITrQprIjF2fCo9HlRTNETktx13s31trD1sCpTunYRnLrcCaEJdMDLVsVRXxIrMF4thEFwU3pOkjMHgD27fAGfoLSJfghU59938MuBBW8Zx51NE6Ylza9G0giTb0TVSE0ajaGextQW5DC5hLmbx8kk3-d3WfslYv2nLflwh4JtSZsXDsumnivWG_YXU1SvpL7MUdr9a8kWeUkA6NTQhXVLb-38N-BQBzOiU8TGaf5FQ86B8BEi8z3w-mBwM2hrOR7n1AgmrqmJXM7YbvkUBLZPCZX_F2ekPNItr8HQEbPAVGrftAwyp7gLWSz_7Ysypy_88LZnTTD1Xy9GBtPouveXtedfEMMY6vuDoW2FURwibxwv0PbLxFk96kHUX8imEMaKKMZSyvP1xe93-xyyw4XMdwgckJluU0gviMUtJDbT_Ew1cHzS0yfCVhKawfP0TjAdBeDhAQmhJ6WDzRHSxZ3vs_2yVxbcB7ijqhGsL8YbyBOeBJqUO0tZt-dvZmK0auac24b_yFCClglWUUg3lGuG2OsUNwep6EfYkv__V3ewTOZH0Fr4Xzh5RqU9ChvSGrU1hR4SXjMsfHLnzggPzPionvnNlPQe3QpMLaBsUYDWBiyYHa2EodXwk6lQfU0ur9-BrCoo8iEy_VCTa_epa-6hdQtVHq1MYGfYf2hXZkMBdS_dEgsMjwTUynhAeiqv11VCd6wkttc_atN5kAMWydBwMqMvW9vdwart1CGTFNpi_9moVkZc2nNEUfBLFARey_Tc0Ry4nkCYRzePJ65sY-Bn1u_xdDQPgneoP7locVhqF6G040K0ZKBkQQaUwPO20KbWHlGuPphOk7YW4GPo0eD1k34PlcXjlaNH6VZxW9yUGUsa6BG52eP9SggiowJDGkRzuOknLBRCuaRTopLayPRaSrPu3rnYy3_ggvjGuIQDH4nsikK_SANDA8yJEfl6KQJbRJyNw3nxi8kQgRcjctPfKDmHUsWS0Q7RzoN_4JUzM1mcT74ciBJ26gx72iwAJQYPWzMvazEvgop2Wv4ii8aaHccbkpDaUd85I-gv0G5hZyIVRB_pDLvL5kw0Q2VVsIeBtcX5_RN6e8PJTlNAWxdlRWwe9-vHAnwsIIqi4BlA9-URdj2flUxZwitLzfbPOkFUQ_QcJqUURRnlrmgUwbiYIZrCU76nuyfnuCpm1kkktajGHCq8Re86kjkGU28ZcuZ-uQ1ewIyHyartYNaaFuKZg4MoqGJySvf_gWReAWIC3vvxo5REgvj3h7MkJNI6Q4D0B3HT6MJ5dZpxQHocM_8tfIhFz9tuFsnhbbWcqsCNoDqh7XSkf66sLsBRN4Mg_te9r4HUa9I3o9IEWyyeIfM7GTW3tSUUFwB9hmLYlQDYUgZVO1glHckZQrMVVThZGcJ5hnYJ3UaSG6VvFV6EokTF3fDJgAyzkanqZ8sZMafWg_gqVQ_ldM6ck1rE9hnXs_ZfuJr43_9FotXDljE01TWKiCs1UUjf9G5BjZmenunKCirDmxDeVuEJD9zDQ29Y3XecAGNoL_IZE8uvZIvVog70x-wq1hHWko5UQzhu8DuXHqiW1DVDpEf4UxzEi-DvFVOTfbVP_5f-9Sra_NP5P2WOsXrmsmS9n6IHs2vB10Vrqj5oU0r6sHcnUEs525NbRK16YLSVHrfEeuOJ9pgI9VAmZaBkyqCw7aWAGrWh_WptCtfaBKaCuCOJNdVEyM66a78MyPGZM7a8wEv1655O7HIJKQQcO-jHWi0U7vZx6UB5WxX9nvB9yx8tl9zyD3HMsjBOkla33_3AjVvreJ1mGAvCu_EEEDXnRDQ512bTxqDodE4Epb9Qm5Xbls6my0L-qQIAZGJ6Z8l5RCpOP08q-6oRRHBmPxQ2vrJJnYIxFnXNyR9b31DSBvUQukWO70Fra2fXEhvKdUTja5_jPRq-OfuzfMG5Qb8QRdyRYE4Tne2RMPPyuwJ3O6ufEiao1ptxHalwZDcW-NA8CIphLGHj5_8pT6k0DjKzX_L1l_33c5HNHzUvS362EMon5ycJAxRmU3AbJFae2_Y7IOlnlywyoeRvnPS8cwZVSoNTt_hCH8_YM36KdMfaDqanU28eQky0Vucj8pceoHunSefNg8v58U1aM9YU61ijIUShNcUosDH0UnQYviL-COkSBIQkyMvYbspaVU6ahUZ1Tst3k0Z8plUx-IKVD8inbifieyF_wUh3s-tQJg1_Kwno06H5hlQ3C3eIt1VmjvKdnyuwOTL5eDAlUWu5UBekMxzkMZUhFxHOjSFZytVxhCAcUfw-8aMO0DSUFMWUJ31LvW6X3cN7SfPfxl0sEkz3LAhtBc6AH2ED-hOfAqyu8Ei0sfsuyNsRE_sDIJqb6mny159Msuf7r3_lOTUzLlYtvEb6mRnJkzON-HFAAmFE6yQC_l1x7aPWyJWx7IWFpH8XOMC7c0H9y7Fhnk7yWj6o_VO9zk4xvX_7g1O6rGs0fNXYQPMN75gUcTh24lNtWbcO4P9eV8tDN0PfHjTRqdNYJwL9Xq7hT1aKZbtXxDg0Gw1ZGYorFEiw0y_tsDf_qCo5tOypMtO_AhCYequMzX2BygQ1eyVvn_VQKDzZECjEGOddEchLz9ljeL_7N9i7PzsBAyNOmYu_jZe8dChQwv52TV1uDEQJY_sks9gzSyEbJQS1OgftvdY2ovJ3-tbN6vah7URicJSwL5jgrpZTG779NFb5uywaX6EBGZ03-93VMRfMcK-rrBhwZegx2G8-YcvJPS7xyYA4SvehghlfhBr-FmkIBIukv15sh92AvnXTKExmXhV8RGlMABM9GdAo4XNP9I_xXYZh75ew2jP6l-64MIDh47vVxInlTfPpHJegRGk1PYx9zx18rkEY7TP1OkEzpknijKPKkrV-ttO0pVa7-aNE4v1U5s6HBIQd3S9E2wjEA_mKMDEkedI8kHJNGkzbgZ95Xqm521VGIn-VflOhz1MTR2kCsj5c2E_0Nm0dA9b5qyWPftxq2hauT7C637p-4-T410s8UJkGpZX1XNitCCUKdzTFb1jgC0GZGNUUuZ5fkVnlL9GtAZOOom7xtByTVeoER-8x8vOQxF2PiwcurtNpenl-bm861DoV2_0Tz9VtPa2AQO0YGYPE7AScR-cvAZg5VLgAh_xztg792gTkhsfRoVATBADBOt4VDVQJfhTeH_rfxfSnK96eDANS-lphZAjyK2-tfQ2432B7agl73B-afHUlIJ5SsOLHiZzoNB7X8Q1XbzOny6aBNhGjSSlwk1kIJBwOOoTawXlDbtIh7_9ClhC5TmSNLn4poazG3FMpEA4mJBHzghK9qoCjG7KjDrmLvbWiVghSm0r5E0zO0uZG8Kod0Q1JnMX-4mhxjIbHDvXpWLn3qXJRwoyO_c_JngcK8hVZka2hPlO635AjKYbIJRGcmq07wM_6RS5WJsKvGeDOtuefZWAdruCr14uHMbg-z80NUbHOWjHAbN-fRqt07E8wJV04T4qT0d3VpRIZIrkNIRT7jeXHQ7w0vM3C5czOJGNQj0Mt2l6Cx9ysBmGUv6WtL-HQ587iBIvSmDyqedgGZUbHmuqBQINRgAghpFSzRu5HpmPNA5VrU1z3Q8cjmn5TRKu5C_3Z_ymviGpjWGmgBeDHQs0xlPCN6RQGIJMzdDpBV3eXL8YT7t5YEgul6hH4viET5YbuwlWiUkYXfqSBvjT9-YqBVTJh8KpdBUG05z3TryL-gB2V3wmnQAORydHGsi8lKlKWmC3MTNY1R1yCHAaW--nL7Tk3Ja4KzJsYusDoZi5YfTdzYoEbVnRlOTuKLctopLaSiZZ5b6E0CdwKfXRWVeOOcjpI2ArKOWWMnZX07bd8V3_2JKrPSwz22W2HIjCy2eGZ_RH5dZq25_Ok9SBAAoX63sX2dVvVbEwyoE-oP-uttq5l5Tr1v-bU0OJrfRm3VEHF2pTuUeZtn_Kh9q0F7OCBIw0C54ENYne-cDRSEGW2U9MMZ9EEmtOt_1b4tHZH-SwJAp42737WdaYKDcR8rtdPYsyOeTpm8gtWI2-dx-u3AozWYLBw8Lv6EnnFZ-13QsnIMMw6dKyYPKLw8lGNqWGlbxLGAE76_hKEfBm8flRvl27jY6N7mZt3wWkxfyD9RUyHkFk_uSB8O5vBUms1XfcDUTVJmkMkYYuAMEQeoyMXzroVAPHMYRqIEz43KZX0lwMsASaiUpYZ73In9SL_Xbi1T26Ii0PtgQh6CDVRCGH5l16g-Lv4pQq1gynjqMISn-p4K27ItYnhesQlUQ4rcI0FqPT9Wgkt0w-aBThnfpN1KtXw3sd0zATpAXglFo.IBhh9W88uPJ6wMwuZgf_5kZXKh1ZszjVBdfDSLPeE-Q\",\"scope\":\"openid profile email offline_access ui.read ui.admin ui.preferences.read ui.preferences.write authority:tenants.read authority:tenants.write authority:users.read authority:users.write authority:roles.read authority:roles.write authority:clients.read authority:clients.write authority:tokens.read authority:tokens.revoke authority:branding.read authority:branding.write authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve policy:run policy:activate policy:audit policy:edit policy:operate policy:publish airgap:seal airgap:status:read orch:read orch:operate orch:quota analytics.read advisory:read advisory-ai:view advisory-ai:operate vex:read vexhub:read exceptions:read exceptions:approve aoc:verify findings:read release:read release:write release:publish scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate evidence:read export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit platform.context.read platform.context.write doctor:run doctor:admin ops.health integration:read integration:write integration:operate packs.read packs.write packs.run packs.approve registry.admin timeline:read timeline:write trust:read trust:write trust:admin signer:read signer:sign signer:rotate signer:admin\",\"expiresAtEpochMs\":1776599471290},\"identity\":{\"subject\":\"f945f00811f44f008058268a264ed015\",\"roles\":[],\"idToken\":\"eyJhbGciOiJSUzI1NiIsImtpZCI6IjdLUks4OUw1UTNESkVQR09IV1NPSDNMVDktTVdNWVVJRS1aSU1VX1EiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2F1dGhvcml0eS5zdGVsbGEtb3BzLmxvY2FsLyIsImV4cCI6MTc3NjYwMDk3MiwiaWF0IjoxNzc2NTk3NjcyLCJhdWQiOiJzdGVsbGEtb3BzLXVpIiwic3ViIjoiZjk0NWYwMDgxMWY0NGYwMDgwNTgyNjhhMjY0ZWQwMTUiLCJhenAiOiJzdGVsbGEtb3BzLXVpIiwibm9uY2UiOiI3NzIxM2QzMS0wOTY3LTQ0MTItYjE1Ny1hM2RhN2Q3NDZkNjIiLCJhdF9oYXNoIjoiSElrZ1l3ZGZyc213S1B1ZTdjdW1wZyJ9.A9Mlpx8wxmuVLEl2P2hBYs8TXpgLLsAkfr2ywoOj-XBdgOWjjoKx7Uc0S6JqXZjj0aAEtiFTX0pT5QvWwWUhRzKIk_osSIt5eim0lyY3130O1b-zK4xJxLr98ukT-ikhcchJCxTjwyE_m8Mx3ZIyxj52nrWZmFEhx6is2BFgA8OXZmk6UgMFi2PKYjKP9qy_kz_ZcdpHjyaxgU4nyB8giXPWQjBEjZ3bYdotn5BUMQQ4i3NVT-UeriLZnHDb2D147WL0E7KfzoZznkNpKaCnDUo_09MSVXPKhRXqNaH9DYQQ8pMPCcVMDHxMjUEd5ycvAl8LJVSy32-1l1ix9lgJTQ\"},\"dpopKeyThumbprint\":\"oE_VLfMwaUO1GgDCBKUb__qedb99FPz82TOxT8HJZTQ\",\"issuedAtEpochMs\":1776597671291,\"tenantId\":\"default\",\"scopes\":[\"advisory-ai:operate\",\"advisory-ai:view\",\"advisory:read\",\"airgap:seal\",\"airgap:status:read\",\"analytics.read\",\"aoc:verify\",\"authority.audit.read\",\"authority:branding.read\",\"authority:branding.write\",\"authority:clients.read\",\"authority:clients.write\",\"authority:roles.read\",\"authority:roles.write\",\"authority:tenants.read\",\"authority:tenants.write\",\"authority:tokens.read\",\"authority:tokens.revoke\",\"authority:users.read\",\"authority:users.write\",\"doctor:admin\",\"doctor:run\",\"email\",\"evidence:read\",\"exceptions:approve\",\"exceptions:read\",\"export.admin\",\"export.operator\",\"export.viewer\",\"findings:read\",\"graph:read\",\"integration:operate\",\"integration:read\",\"integration:write\",\"notify.admin\",\"notify.escalate\",\"notify.operator\",\"notify.viewer\",\"offline_access\",\"openid\",\"ops.health\",\"orch:operate\",\"orch:quota\",\"orch:read\",\"packs.approve\",\"packs.read\",\"packs.run\",\"packs.write\",\"platform.context.read\",\"platform.context.write\",\"policy:activate\",\"policy:approve\",\"policy:audit\",\"policy:author\",\"policy:edit\",\"policy:operate\",\"policy:publish\",\"policy:read\",\"policy:review\",\"policy:run\",\"policy:simulate\",\"profile\",\"registry.admin\",\"release:publish\",\"release:read\",\"release:write\",\"sbom:read\",\"scanner:read\",\"scheduler:operate\",\"scheduler:read\",\"signer:admin\",\"signer:read\",\"signer:rotate\",\"signer:sign\",\"timeline:read\",\"timeline:write\",\"trust:admin\",\"trust:read\",\"trust:write\",\"ui.admin\",\"ui.preferences.read\",\"ui.preferences.write\",\"ui.read\",\"vex:read\",\"vexhub:read\",\"vuln:audit\",\"vuln:investigate\",\"vuln:operate\",\"vuln:view\"],\"audiences\":[],\"authenticationTimeEpochMs\":1776597671000,\"freshAuthActive\":false,\"freshAuthExpiresAtEpochMs\":null}" + ], + [ + "stellaops.helper.preferences", + "{\"dismissed\":false,\"tooltipsMuted\":false,\"mutedPages\":[],\"mutedTipIds\":[],\"seenPages\":[],\"tipIndex\":{},\"dismissedBanners\":[],\"seenHelpPages\":[],\"pageHelpOpen\":{},\"pageHelpDismissedGlobal\":false,\"pageHelpDismissedPages\":[]}" + ], + [ + "stellaops.content-width", + "centered" + ], + [ + "stellaops.assistant.state", + "{\"seenRoutes\":[],\"completedTours\":[],\"tipPositions\":{},\"dismissed\":false}" + ], + [ + "stellaops.theme", + "system" + ], + [ + "stellaops.auth.session.info", + "{\"subject\":\"f945f00811f44f008058268a264ed015\",\"expiresAtEpochMs\":1776599471290,\"issuedAtEpochMs\":1776597671291,\"dpopKeyThumbprint\":\"oE_VLfMwaUO1GgDCBKUb__qedb99FPz82TOxT8HJZTQ\",\"tenantId\":\"default\"}" + ], + [ + "stellaops.sidebar.preferences", + "{\"sidebarCollapsed\":false,\"collapsedGroups\":[\"evidence\",\"setup-admin\"],\"collapsedSections\":[]}" + ] + ], + "sessionStorageEntries": [ + [ + "stellaops.auth.session.full", + "{\"tokens\":{\"accessToken\":\"eyJhbGciOiJSUzI1NiIsImtpZCI6IjdLUks4OUw1UTNESkVQR09IV1NPSDNMVDktTVdNWVVJRS1aSU1VX1EiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2F1dGhvcml0eS5zdGVsbGEtb3BzLmxvY2FsLyIsImV4cCI6MTc3NjU5OTQ3MiwiaWF0IjoxNzc2NTk3NjcyLCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIG9mZmxpbmVfYWNjZXNzIHVpLnJlYWQgdWkuYWRtaW4gdWkucHJlZmVyZW5jZXMucmVhZCB1aS5wcmVmZXJlbmNlcy53cml0ZSBhdXRob3JpdHk6dGVuYW50cy5yZWFkIGF1dGhvcml0eTp0ZW5hbnRzLndyaXRlIGF1dGhvcml0eTp1c2Vycy5yZWFkIGF1dGhvcml0eTp1c2Vycy53cml0ZSBhdXRob3JpdHk6cm9sZXMucmVhZCBhdXRob3JpdHk6cm9sZXMud3JpdGUgYXV0aG9yaXR5OmNsaWVudHMucmVhZCBhdXRob3JpdHk6Y2xpZW50cy53cml0ZSBhdXRob3JpdHk6dG9rZW5zLnJlYWQgYXV0aG9yaXR5OnRva2Vucy5yZXZva2UgYXV0aG9yaXR5OmJyYW5kaW5nLnJlYWQgYXV0aG9yaXR5OmJyYW5kaW5nLndyaXRlIGF1dGhvcml0eS5hdWRpdC5yZWFkIGdyYXBoOnJlYWQgc2JvbTpyZWFkIHNjYW5uZXI6cmVhZCBwb2xpY3k6cmVhZCBwb2xpY3k6c2ltdWxhdGUgcG9saWN5OmF1dGhvciBwb2xpY3k6cmV2aWV3IHBvbGljeTphcHByb3ZlIHBvbGljeTpydW4gcG9saWN5OmFjdGl2YXRlIHBvbGljeTphdWRpdCBwb2xpY3k6ZWRpdCBwb2xpY3k6b3BlcmF0ZSBwb2xpY3k6cHVibGlzaCBhaXJnYXA6c2VhbCBhaXJnYXA6c3RhdHVzOnJlYWQgb3JjaDpyZWFkIG9yY2g6b3BlcmF0ZSBvcmNoOnF1b3RhIGFuYWx5dGljcy5yZWFkIGFkdmlzb3J5OnJlYWQgYWR2aXNvcnktYWk6dmlldyBhZHZpc29yeS1haTpvcGVyYXRlIHZleDpyZWFkIHZleGh1YjpyZWFkIGV4Y2VwdGlvbnM6cmVhZCBleGNlcHRpb25zOmFwcHJvdmUgYW9jOnZlcmlmeSBmaW5kaW5nczpyZWFkIHJlbGVhc2U6cmVhZCByZWxlYXNlOndyaXRlIHJlbGVhc2U6cHVibGlzaCBzY2hlZHVsZXI6cmVhZCBzY2hlZHVsZXI6b3BlcmF0ZSBub3RpZnkudmlld2VyIG5vdGlmeS5vcGVyYXRvciBub3RpZnkuYWRtaW4gbm90aWZ5LmVzY2FsYXRlIGV2aWRlbmNlOnJlYWQgZXhwb3J0LnZpZXdlciBleHBvcnQub3BlcmF0b3IgZXhwb3J0LmFkbWluIHZ1bG46dmlldyB2dWxuOmludmVzdGlnYXRlIHZ1bG46b3BlcmF0ZSB2dWxuOmF1ZGl0IHBsYXRmb3JtLmNvbnRleHQucmVhZCBwbGF0Zm9ybS5jb250ZXh0LndyaXRlIGRvY3RvcjpydW4gZG9jdG9yOmFkbWluIG9wcy5oZWFsdGggaW50ZWdyYXRpb246cmVhZCBpbnRlZ3JhdGlvbjp3cml0ZSBpbnRlZ3JhdGlvbjpvcGVyYXRlIHBhY2tzLnJlYWQgcGFja3Mud3JpdGUgcGFja3MucnVuIHBhY2tzLmFwcHJvdmUgcmVnaXN0cnkuYWRtaW4gdGltZWxpbmU6cmVhZCB0aW1lbGluZTp3cml0ZSB0cnVzdDpyZWFkIHRydXN0OndyaXRlIHRydXN0OmFkbWluIHNpZ25lcjpyZWFkIHNpZ25lcjpzaWduIHNpZ25lcjpyb3RhdGUgc2lnbmVyOmFkbWluIiwianRpIjoiOWJhYWRkZmUtOGVhYi00YjdlLWIxNDAtYjdkYmM2ZGY0NDY1Iiwic3ViIjoiZjk0NWYwMDgxMWY0NGYwMDgwNTgyNjhhMjY0ZWQwMTUiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOiJhZG1pbiIsInN0ZWxsYW9wczp0ZW5hbnQiOiJkZWZhdWx0IiwiYXV0aF90aW1lIjoxNzc2NTk3NjcxLCJvaV9wcnN0Ijoic3RlbGxhLW9wcy11aSIsImNsaWVudF9pZCI6InN0ZWxsYS1vcHMtdWkifQ.LTsaW7eUNYBfwjxklmhF24qfW9BP1Jco03vZppZeVJyToGF0mT0_Sr1vdfa_spRIlPUVYjDrRHhAIMuAQak350DXzJqF9nY4kvhQzW07loyB5ASCdt1IBijRqjXVp8nGVVP5_pGZiLbX34AxRRbfrLE7KwRFkf0NZaochfBhRjqoLN2uRw-IIGkJMNthFae8qPmNvFvDaUruygUDIXv2yUVS_NnXXYJDOy9BC35akAlEqsEG-MGSomuUbo-YeS4hjpBG7mF7LX4UiUYFkePmRWCKVDJDPYExPp3j3n0cMQaqQci1OiKvbX3jJYCM1wkMXSV28ZeRYC9ZjNZVJgG76Q\",\"tokenType\":\"Bearer\",\"refreshToken\":\"eyJhbGciOiJSU0EtT0FFUCIsImVuYyI6IkEyNTZDQkMtSFM1MTIiLCJraWQiOiJZV0pPRVlCSzU3UEpSQTFTSFhFWkM3NEFRQktRWFdEQjZaWlVPV0dVIiwidHlwIjoib2lfcmVmdCtqd3QiLCJjdHkiOiJKV1QifQ.yCZRh1VwHde8Mw6susd190XjLjX4CYt4XMW_es2vU1YFnLm1gOjya3tVSPjNnzOW57EWn_0q4nTSocTNJFV8JDGfR5S92xRbvCBkV54MKWgILqsoRY04eU1Q1fszPybTZqTxj-rX-IMiAJ0EXqxtTLJdxMFN5sdQYcKOMdZV6S6dBArSEMdfdVQKCtuEAKdUEpILsOcI4S4x3B8z8tlw5DsCdiFgW3M2mijFrKppiPGiKDnN1upeZ6StoaUu-Ijnw_hZIo2vbBXINAe-5p_naJ-6JxAGP_UlCPNtWvIZuIA6qV-kldCDE8kWARn1rnXCqB5Zjg3wwulVjlh5u7__0w.snJwpNse2FWAMfsSA08jSg.4Lngki9KU2RPD0KkchsxkAtAaITJR0Hv_gvL4Rfn3-UOif2kvIGk3iX8G3Xo0gvdem7x0_70EMM0jLgZT-tvxc5gm10lbuD3R1nCefcgJcAD3zBN-UKKbNRVe8VxTWNNQXhK8bAEvphjgR3yhQt6LyxQw2-arjkz947kRPctj9UmRQNItD4oN0KOchEmuGIYhCzi3SKsJxbcembWNPmFU9DKJqFdRotzCy8bMlYBY2Wh744Ue3AhuJeg1YuYd6giQZNowlkuP10O27FwATpfC4iE6Y1rbMFj8ZIXMJn5vwz1gh51n4NCp-qjQOeYCcECV4E3ITrQprIjF2fCo9HlRTNETktx13s31trD1sCpTunYRnLrcCaEJdMDLVsVRXxIrMF4thEFwU3pOkjMHgD27fAGfoLSJfghU59938MuBBW8Zx51NE6Ylza9G0giTb0TVSE0ajaGextQW5DC5hLmbx8kk3-d3WfslYv2nLflwh4JtSZsXDsumnivWG_YXU1SvpL7MUdr9a8kWeUkA6NTQhXVLb-38N-BQBzOiU8TGaf5FQ86B8BEi8z3w-mBwM2hrOR7n1AgmrqmJXM7YbvkUBLZPCZX_F2ekPNItr8HQEbPAVGrftAwyp7gLWSz_7Ysypy_88LZnTTD1Xy9GBtPouveXtedfEMMY6vuDoW2FURwibxwv0PbLxFk96kHUX8imEMaKKMZSyvP1xe93-xyyw4XMdwgckJluU0gviMUtJDbT_Ew1cHzS0yfCVhKawfP0TjAdBeDhAQmhJ6WDzRHSxZ3vs_2yVxbcB7ijqhGsL8YbyBOeBJqUO0tZt-dvZmK0auac24b_yFCClglWUUg3lGuG2OsUNwep6EfYkv__V3ewTOZH0Fr4Xzh5RqU9ChvSGrU1hR4SXjMsfHLnzggPzPionvnNlPQe3QpMLaBsUYDWBiyYHa2EodXwk6lQfU0ur9-BrCoo8iEy_VCTa_epa-6hdQtVHq1MYGfYf2hXZkMBdS_dEgsMjwTUynhAeiqv11VCd6wkttc_atN5kAMWydBwMqMvW9vdwart1CGTFNpi_9moVkZc2nNEUfBLFARey_Tc0Ry4nkCYRzePJ65sY-Bn1u_xdDQPgneoP7locVhqF6G040K0ZKBkQQaUwPO20KbWHlGuPphOk7YW4GPo0eD1k34PlcXjlaNH6VZxW9yUGUsa6BG52eP9SggiowJDGkRzuOknLBRCuaRTopLayPRaSrPu3rnYy3_ggvjGuIQDH4nsikK_SANDA8yJEfl6KQJbRJyNw3nxi8kQgRcjctPfKDmHUsWS0Q7RzoN_4JUzM1mcT74ciBJ26gx72iwAJQYPWzMvazEvgop2Wv4ii8aaHccbkpDaUd85I-gv0G5hZyIVRB_pDLvL5kw0Q2VVsIeBtcX5_RN6e8PJTlNAWxdlRWwe9-vHAnwsIIqi4BlA9-URdj2flUxZwitLzfbPOkFUQ_QcJqUURRnlrmgUwbiYIZrCU76nuyfnuCpm1kkktajGHCq8Re86kjkGU28ZcuZ-uQ1ewIyHyartYNaaFuKZg4MoqGJySvf_gWReAWIC3vvxo5REgvj3h7MkJNI6Q4D0B3HT6MJ5dZpxQHocM_8tfIhFz9tuFsnhbbWcqsCNoDqh7XSkf66sLsBRN4Mg_te9r4HUa9I3o9IEWyyeIfM7GTW3tSUUFwB9hmLYlQDYUgZVO1glHckZQrMVVThZGcJ5hnYJ3UaSG6VvFV6EokTF3fDJgAyzkanqZ8sZMafWg_gqVQ_ldM6ck1rE9hnXs_ZfuJr43_9FotXDljE01TWKiCs1UUjf9G5BjZmenunKCirDmxDeVuEJD9zDQ29Y3XecAGNoL_IZE8uvZIvVog70x-wq1hHWko5UQzhu8DuXHqiW1DVDpEf4UxzEi-DvFVOTfbVP_5f-9Sra_NP5P2WOsXrmsmS9n6IHs2vB10Vrqj5oU0r6sHcnUEs525NbRK16YLSVHrfEeuOJ9pgI9VAmZaBkyqCw7aWAGrWh_WptCtfaBKaCuCOJNdVEyM66a78MyPGZM7a8wEv1655O7HIJKQQcO-jHWi0U7vZx6UB5WxX9nvB9yx8tl9zyD3HMsjBOkla33_3AjVvreJ1mGAvCu_EEEDXnRDQ512bTxqDodE4Epb9Qm5Xbls6my0L-qQIAZGJ6Z8l5RCpOP08q-6oRRHBmPxQ2vrJJnYIxFnXNyR9b31DSBvUQukWO70Fra2fXEhvKdUTja5_jPRq-OfuzfMG5Qb8QRdyRYE4Tne2RMPPyuwJ3O6ufEiao1ptxHalwZDcW-NA8CIphLGHj5_8pT6k0DjKzX_L1l_33c5HNHzUvS362EMon5ycJAxRmU3AbJFae2_Y7IOlnlywyoeRvnPS8cwZVSoNTt_hCH8_YM36KdMfaDqanU28eQky0Vucj8pceoHunSefNg8v58U1aM9YU61ijIUShNcUosDH0UnQYviL-COkSBIQkyMvYbspaVU6ahUZ1Tst3k0Z8plUx-IKVD8inbifieyF_wUh3s-tQJg1_Kwno06H5hlQ3C3eIt1VmjvKdnyuwOTL5eDAlUWu5UBekMxzkMZUhFxHOjSFZytVxhCAcUfw-8aMO0DSUFMWUJ31LvW6X3cN7SfPfxl0sEkz3LAhtBc6AH2ED-hOfAqyu8Ei0sfsuyNsRE_sDIJqb6mny159Msuf7r3_lOTUzLlYtvEb6mRnJkzON-HFAAmFE6yQC_l1x7aPWyJWx7IWFpH8XOMC7c0H9y7Fhnk7yWj6o_VO9zk4xvX_7g1O6rGs0fNXYQPMN75gUcTh24lNtWbcO4P9eV8tDN0PfHjTRqdNYJwL9Xq7hT1aKZbtXxDg0Gw1ZGYorFEiw0y_tsDf_qCo5tOypMtO_AhCYequMzX2BygQ1eyVvn_VQKDzZECjEGOddEchLz9ljeL_7N9i7PzsBAyNOmYu_jZe8dChQwv52TV1uDEQJY_sks9gzSyEbJQS1OgftvdY2ovJ3-tbN6vah7URicJSwL5jgrpZTG779NFb5uywaX6EBGZ03-93VMRfMcK-rrBhwZegx2G8-YcvJPS7xyYA4SvehghlfhBr-FmkIBIukv15sh92AvnXTKExmXhV8RGlMABM9GdAo4XNP9I_xXYZh75ew2jP6l-64MIDh47vVxInlTfPpHJegRGk1PYx9zx18rkEY7TP1OkEzpknijKPKkrV-ttO0pVa7-aNE4v1U5s6HBIQd3S9E2wjEA_mKMDEkedI8kHJNGkzbgZ95Xqm521VGIn-VflOhz1MTR2kCsj5c2E_0Nm0dA9b5qyWPftxq2hauT7C637p-4-T410s8UJkGpZX1XNitCCUKdzTFb1jgC0GZGNUUuZ5fkVnlL9GtAZOOom7xtByTVeoER-8x8vOQxF2PiwcurtNpenl-bm861DoV2_0Tz9VtPa2AQO0YGYPE7AScR-cvAZg5VLgAh_xztg792gTkhsfRoVATBADBOt4VDVQJfhTeH_rfxfSnK96eDANS-lphZAjyK2-tfQ2432B7agl73B-afHUlIJ5SsOLHiZzoNB7X8Q1XbzOny6aBNhGjSSlwk1kIJBwOOoTawXlDbtIh7_9ClhC5TmSNLn4poazG3FMpEA4mJBHzghK9qoCjG7KjDrmLvbWiVghSm0r5E0zO0uZG8Kod0Q1JnMX-4mhxjIbHDvXpWLn3qXJRwoyO_c_JngcK8hVZka2hPlO635AjKYbIJRGcmq07wM_6RS5WJsKvGeDOtuefZWAdruCr14uHMbg-z80NUbHOWjHAbN-fRqt07E8wJV04T4qT0d3VpRIZIrkNIRT7jeXHQ7w0vM3C5czOJGNQj0Mt2l6Cx9ysBmGUv6WtL-HQ587iBIvSmDyqedgGZUbHmuqBQINRgAghpFSzRu5HpmPNA5VrU1z3Q8cjmn5TRKu5C_3Z_ymviGpjWGmgBeDHQs0xlPCN6RQGIJMzdDpBV3eXL8YT7t5YEgul6hH4viET5YbuwlWiUkYXfqSBvjT9-YqBVTJh8KpdBUG05z3TryL-gB2V3wmnQAORydHGsi8lKlKWmC3MTNY1R1yCHAaW--nL7Tk3Ja4KzJsYusDoZi5YfTdzYoEbVnRlOTuKLctopLaSiZZ5b6E0CdwKfXRWVeOOcjpI2ArKOWWMnZX07bd8V3_2JKrPSwz22W2HIjCy2eGZ_RH5dZq25_Ok9SBAAoX63sX2dVvVbEwyoE-oP-uttq5l5Tr1v-bU0OJrfRm3VEHF2pTuUeZtn_Kh9q0F7OCBIw0C54ENYne-cDRSEGW2U9MMZ9EEmtOt_1b4tHZH-SwJAp42737WdaYKDcR8rtdPYsyOeTpm8gtWI2-dx-u3AozWYLBw8Lv6EnnFZ-13QsnIMMw6dKyYPKLw8lGNqWGlbxLGAE76_hKEfBm8flRvl27jY6N7mZt3wWkxfyD9RUyHkFk_uSB8O5vBUms1XfcDUTVJmkMkYYuAMEQeoyMXzroVAPHMYRqIEz43KZX0lwMsASaiUpYZ73In9SL_Xbi1T26Ii0PtgQh6CDVRCGH5l16g-Lv4pQq1gynjqMISn-p4K27ItYnhesQlUQ4rcI0FqPT9Wgkt0w-aBThnfpN1KtXw3sd0zATpAXglFo.IBhh9W88uPJ6wMwuZgf_5kZXKh1ZszjVBdfDSLPeE-Q\",\"scope\":\"openid profile email offline_access ui.read ui.admin ui.preferences.read ui.preferences.write authority:tenants.read authority:tenants.write authority:users.read authority:users.write authority:roles.read authority:roles.write authority:clients.read authority:clients.write authority:tokens.read authority:tokens.revoke authority:branding.read authority:branding.write authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve policy:run policy:activate policy:audit policy:edit policy:operate policy:publish airgap:seal airgap:status:read orch:read orch:operate orch:quota analytics.read advisory:read advisory-ai:view advisory-ai:operate vex:read vexhub:read exceptions:read exceptions:approve aoc:verify findings:read release:read release:write release:publish scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate evidence:read export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit platform.context.read platform.context.write doctor:run doctor:admin ops.health integration:read integration:write integration:operate packs.read packs.write packs.run packs.approve registry.admin timeline:read timeline:write trust:read trust:write trust:admin signer:read signer:sign signer:rotate signer:admin\",\"expiresAtEpochMs\":1776599471290},\"identity\":{\"subject\":\"f945f00811f44f008058268a264ed015\",\"roles\":[],\"idToken\":\"eyJhbGciOiJSUzI1NiIsImtpZCI6IjdLUks4OUw1UTNESkVQR09IV1NPSDNMVDktTVdNWVVJRS1aSU1VX1EiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2F1dGhvcml0eS5zdGVsbGEtb3BzLmxvY2FsLyIsImV4cCI6MTc3NjYwMDk3MiwiaWF0IjoxNzc2NTk3NjcyLCJhdWQiOiJzdGVsbGEtb3BzLXVpIiwic3ViIjoiZjk0NWYwMDgxMWY0NGYwMDgwNTgyNjhhMjY0ZWQwMTUiLCJhenAiOiJzdGVsbGEtb3BzLXVpIiwibm9uY2UiOiI3NzIxM2QzMS0wOTY3LTQ0MTItYjE1Ny1hM2RhN2Q3NDZkNjIiLCJhdF9oYXNoIjoiSElrZ1l3ZGZyc213S1B1ZTdjdW1wZyJ9.A9Mlpx8wxmuVLEl2P2hBYs8TXpgLLsAkfr2ywoOj-XBdgOWjjoKx7Uc0S6JqXZjj0aAEtiFTX0pT5QvWwWUhRzKIk_osSIt5eim0lyY3130O1b-zK4xJxLr98ukT-ikhcchJCxTjwyE_m8Mx3ZIyxj52nrWZmFEhx6is2BFgA8OXZmk6UgMFi2PKYjKP9qy_kz_ZcdpHjyaxgU4nyB8giXPWQjBEjZ3bYdotn5BUMQQ4i3NVT-UeriLZnHDb2D147WL0E7KfzoZznkNpKaCnDUo_09MSVXPKhRXqNaH9DYQQ8pMPCcVMDHxMjUEd5ycvAl8LJVSy32-1l1ix9lgJTQ\"},\"dpopKeyThumbprint\":\"oE_VLfMwaUO1GgDCBKUb__qedb99FPz82TOxT8HJZTQ\",\"issuedAtEpochMs\":1776597671291,\"tenantId\":\"default\",\"scopes\":[\"advisory-ai:operate\",\"advisory-ai:view\",\"advisory:read\",\"airgap:seal\",\"airgap:status:read\",\"analytics.read\",\"aoc:verify\",\"authority.audit.read\",\"authority:branding.read\",\"authority:branding.write\",\"authority:clients.read\",\"authority:clients.write\",\"authority:roles.read\",\"authority:roles.write\",\"authority:tenants.read\",\"authority:tenants.write\",\"authority:tokens.read\",\"authority:tokens.revoke\",\"authority:users.read\",\"authority:users.write\",\"doctor:admin\",\"doctor:run\",\"email\",\"evidence:read\",\"exceptions:approve\",\"exceptions:read\",\"export.admin\",\"export.operator\",\"export.viewer\",\"findings:read\",\"graph:read\",\"integration:operate\",\"integration:read\",\"integration:write\",\"notify.admin\",\"notify.escalate\",\"notify.operator\",\"notify.viewer\",\"offline_access\",\"openid\",\"ops.health\",\"orch:operate\",\"orch:quota\",\"orch:read\",\"packs.approve\",\"packs.read\",\"packs.run\",\"packs.write\",\"platform.context.read\",\"platform.context.write\",\"policy:activate\",\"policy:approve\",\"policy:audit\",\"policy:author\",\"policy:edit\",\"policy:operate\",\"policy:publish\",\"policy:read\",\"policy:review\",\"policy:run\",\"policy:simulate\",\"profile\",\"registry.admin\",\"release:publish\",\"release:read\",\"release:write\",\"sbom:read\",\"scanner:read\",\"scheduler:operate\",\"scheduler:read\",\"signer:admin\",\"signer:read\",\"signer:rotate\",\"signer:sign\",\"timeline:read\",\"timeline:write\",\"trust:admin\",\"trust:read\",\"trust:write\",\"ui.admin\",\"ui.preferences.read\",\"ui.preferences.write\",\"ui.read\",\"vex:read\",\"vexhub:read\",\"vuln:audit\",\"vuln:investigate\",\"vuln:operate\",\"vuln:view\"],\"audiences\":[],\"authenticationTimeEpochMs\":1776597671000,\"freshAuthActive\":false,\"freshAuthExpiresAtEpochMs\":null}" + ], + [ + "stellaops:wasEverAuth", + "true" + ] + ] + }, + "sessionStatus": { + "hasFullSession": true, + "hasSessionInfo": true + }, + "events": { + "consoleErrors": [], + "requestFailures": [], + "responseErrors": [] + }, + "statePath": "C:\\dev\\New folder\\git.stella-ops.org\\src\\Web\\StellaOps.Web\\output\\playwright\\live-setup-wizard-first-run-bootstrap.state.json", + "screenshotPath": null +} diff --git a/src/Web/StellaOps.Web/output/playwright/live-setup-wizard-first-run-bootstrap.json b/src/Web/StellaOps.Web/output/playwright/live-setup-wizard-first-run-bootstrap.json new file mode 100644 index 000000000..f92845a5b --- /dev/null +++ b/src/Web/StellaOps.Web/output/playwright/live-setup-wizard-first-run-bootstrap.json @@ -0,0 +1,104 @@ +{ + "generatedAtUtc": "2026-04-19T11:21:17.727Z", + "baseUrl": "https://stella-ops.local", + "adminUsername": "admin", + "adminEmail": "admin@stella-ops.local", + "mode": "already-configured", + "steps": [ + { + "action": "setup-entry-after-authentication", + "ok": true, + "resolvedSurface": "authenticated-reconfigure", + "initialAnonymousSession": { + "status": 401, + "ok": false, + "sessionId": null, + "currentStepId": "", + "sessionStatus": "", + "completedAtUtc": null, + "steps": [], + "raw": { + "status": 401, + "ok": false, + "payload": null, + "bodyText": "" + } + }, + "authenticatedSetupSessionProbe": { + "status": 401, + "ok": false, + "sessionId": null, + "currentStepId": "", + "sessionStatus": "", + "completedAtUtc": null, + "steps": [], + "raw": { + "status": 401, + "ok": false, + "payload": null, + "bodyText": "" + } + }, + "initialSnapshot": { + "label": "initial", + "url": "https://stella-ops.local/setup-wizard/wizard", + "title": "Wizard - StellaOps", + "heading": "Setup", + "alerts": [], + "visibleButtons": [ + "Apply and ContinueWelcome" + ] + }, + "snapshot": { + "label": "setup-already-complete", + "url": "https://stella-ops.local/setup-wizard/wizard", + "title": "Wizard - StellaOps", + "heading": "Setup", + "alerts": [], + "visibleButtons": [ + "Validate Connection", + "Apply and ContinueValkey/Redis Cache" + ] + } + } + ], + "runtime": { + "consoleErrors": [], + "pageErrors": [], + "requestFailures": [], + "responseErrors": [ + { + "page": "https://stella-ops.local/setup-wizard/wizard", + "method": "POST", + "status": 401, + "url": "https://stella-ops.local/api/v1/setup/sessions" + } + ], + "setupApiEvents": [ + { + "page": "https://stella-ops.local/setup-wizard/wizard", + "method": "POST", + "status": 401, + "url": "https://stella-ops.local/api/v1/setup/sessions" + }, + { + "page": "https://stella-ops.local/setup-wizard/wizard", + "method": "POST", + "status": 201, + "url": "https://stella-ops.local/api/v1/setup/sessions" + }, + { + "page": "https://stella-ops.local/setup-wizard/wizard", + "method": "POST", + "status": 200, + "url": "https://stella-ops.local/api/v1/setup/sessions/setup-installation-20260419111949/steps/database/checks/run" + } + ] + }, + "postSetupAuthentication": { + "finalUrl": "https://stella-ops.local/?tenant=default®ions=apac,eu-west,us-east", + "title": "Dashboard - StellaOps", + "reportPath": "C:\\dev\\New folder\\git.stella-ops.org\\src\\Web\\StellaOps.Web\\output\\playwright\\live-setup-wizard-first-run-bootstrap.auth.json", + "statePath": "C:\\dev\\New folder\\git.stella-ops.org\\src\\Web\\StellaOps.Web\\output\\playwright\\live-setup-wizard-first-run-bootstrap.state.json" + } +} diff --git a/src/Web/StellaOps.Web/output/playwright/live-setup-wizard-first-run-bootstrap.state.json b/src/Web/StellaOps.Web/output/playwright/live-setup-wizard-first-run-bootstrap.state.json new file mode 100644 index 000000000..787fa9bd7 --- /dev/null +++ b/src/Web/StellaOps.Web/output/playwright/live-setup-wizard-first-run-bootstrap.state.json @@ -0,0 +1,38 @@ +{ + "cookies": [], + "origins": [ + { + "origin": "https://stella-ops.local", + "localStorage": [ + { + "name": "stellaops.auth.session.full", + "value": "{\"tokens\":{\"accessToken\":\"eyJhbGciOiJSUzI1NiIsImtpZCI6IjdLUks4OUw1UTNESkVQR09IV1NPSDNMVDktTVdNWVVJRS1aSU1VX1EiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2F1dGhvcml0eS5zdGVsbGEtb3BzLmxvY2FsLyIsImV4cCI6MTc3NjU5OTQ3MiwiaWF0IjoxNzc2NTk3NjcyLCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIG9mZmxpbmVfYWNjZXNzIHVpLnJlYWQgdWkuYWRtaW4gdWkucHJlZmVyZW5jZXMucmVhZCB1aS5wcmVmZXJlbmNlcy53cml0ZSBhdXRob3JpdHk6dGVuYW50cy5yZWFkIGF1dGhvcml0eTp0ZW5hbnRzLndyaXRlIGF1dGhvcml0eTp1c2Vycy5yZWFkIGF1dGhvcml0eTp1c2Vycy53cml0ZSBhdXRob3JpdHk6cm9sZXMucmVhZCBhdXRob3JpdHk6cm9sZXMud3JpdGUgYXV0aG9yaXR5OmNsaWVudHMucmVhZCBhdXRob3JpdHk6Y2xpZW50cy53cml0ZSBhdXRob3JpdHk6dG9rZW5zLnJlYWQgYXV0aG9yaXR5OnRva2Vucy5yZXZva2UgYXV0aG9yaXR5OmJyYW5kaW5nLnJlYWQgYXV0aG9yaXR5OmJyYW5kaW5nLndyaXRlIGF1dGhvcml0eS5hdWRpdC5yZWFkIGdyYXBoOnJlYWQgc2JvbTpyZWFkIHNjYW5uZXI6cmVhZCBwb2xpY3k6cmVhZCBwb2xpY3k6c2ltdWxhdGUgcG9saWN5OmF1dGhvciBwb2xpY3k6cmV2aWV3IHBvbGljeTphcHByb3ZlIHBvbGljeTpydW4gcG9saWN5OmFjdGl2YXRlIHBvbGljeTphdWRpdCBwb2xpY3k6ZWRpdCBwb2xpY3k6b3BlcmF0ZSBwb2xpY3k6cHVibGlzaCBhaXJnYXA6c2VhbCBhaXJnYXA6c3RhdHVzOnJlYWQgb3JjaDpyZWFkIG9yY2g6b3BlcmF0ZSBvcmNoOnF1b3RhIGFuYWx5dGljcy5yZWFkIGFkdmlzb3J5OnJlYWQgYWR2aXNvcnktYWk6dmlldyBhZHZpc29yeS1haTpvcGVyYXRlIHZleDpyZWFkIHZleGh1YjpyZWFkIGV4Y2VwdGlvbnM6cmVhZCBleGNlcHRpb25zOmFwcHJvdmUgYW9jOnZlcmlmeSBmaW5kaW5nczpyZWFkIHJlbGVhc2U6cmVhZCByZWxlYXNlOndyaXRlIHJlbGVhc2U6cHVibGlzaCBzY2hlZHVsZXI6cmVhZCBzY2hlZHVsZXI6b3BlcmF0ZSBub3RpZnkudmlld2VyIG5vdGlmeS5vcGVyYXRvciBub3RpZnkuYWRtaW4gbm90aWZ5LmVzY2FsYXRlIGV2aWRlbmNlOnJlYWQgZXhwb3J0LnZpZXdlciBleHBvcnQub3BlcmF0b3IgZXhwb3J0LmFkbWluIHZ1bG46dmlldyB2dWxuOmludmVzdGlnYXRlIHZ1bG46b3BlcmF0ZSB2dWxuOmF1ZGl0IHBsYXRmb3JtLmNvbnRleHQucmVhZCBwbGF0Zm9ybS5jb250ZXh0LndyaXRlIGRvY3RvcjpydW4gZG9jdG9yOmFkbWluIG9wcy5oZWFsdGggaW50ZWdyYXRpb246cmVhZCBpbnRlZ3JhdGlvbjp3cml0ZSBpbnRlZ3JhdGlvbjpvcGVyYXRlIHBhY2tzLnJlYWQgcGFja3Mud3JpdGUgcGFja3MucnVuIHBhY2tzLmFwcHJvdmUgcmVnaXN0cnkuYWRtaW4gdGltZWxpbmU6cmVhZCB0aW1lbGluZTp3cml0ZSB0cnVzdDpyZWFkIHRydXN0OndyaXRlIHRydXN0OmFkbWluIHNpZ25lcjpyZWFkIHNpZ25lcjpzaWduIHNpZ25lcjpyb3RhdGUgc2lnbmVyOmFkbWluIiwianRpIjoiOWJhYWRkZmUtOGVhYi00YjdlLWIxNDAtYjdkYmM2ZGY0NDY1Iiwic3ViIjoiZjk0NWYwMDgxMWY0NGYwMDgwNTgyNjhhMjY0ZWQwMTUiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOiJhZG1pbiIsInN0ZWxsYW9wczp0ZW5hbnQiOiJkZWZhdWx0IiwiYXV0aF90aW1lIjoxNzc2NTk3NjcxLCJvaV9wcnN0Ijoic3RlbGxhLW9wcy11aSIsImNsaWVudF9pZCI6InN0ZWxsYS1vcHMtdWkifQ.LTsaW7eUNYBfwjxklmhF24qfW9BP1Jco03vZppZeVJyToGF0mT0_Sr1vdfa_spRIlPUVYjDrRHhAIMuAQak350DXzJqF9nY4kvhQzW07loyB5ASCdt1IBijRqjXVp8nGVVP5_pGZiLbX34AxRRbfrLE7KwRFkf0NZaochfBhRjqoLN2uRw-IIGkJMNthFae8qPmNvFvDaUruygUDIXv2yUVS_NnXXYJDOy9BC35akAlEqsEG-MGSomuUbo-YeS4hjpBG7mF7LX4UiUYFkePmRWCKVDJDPYExPp3j3n0cMQaqQci1OiKvbX3jJYCM1wkMXSV28ZeRYC9ZjNZVJgG76Q\",\"tokenType\":\"Bearer\",\"refreshToken\":\"eyJhbGciOiJSU0EtT0FFUCIsImVuYyI6IkEyNTZDQkMtSFM1MTIiLCJraWQiOiJZV0pPRVlCSzU3UEpSQTFTSFhFWkM3NEFRQktRWFdEQjZaWlVPV0dVIiwidHlwIjoib2lfcmVmdCtqd3QiLCJjdHkiOiJKV1QifQ.yCZRh1VwHde8Mw6susd190XjLjX4CYt4XMW_es2vU1YFnLm1gOjya3tVSPjNnzOW57EWn_0q4nTSocTNJFV8JDGfR5S92xRbvCBkV54MKWgILqsoRY04eU1Q1fszPybTZqTxj-rX-IMiAJ0EXqxtTLJdxMFN5sdQYcKOMdZV6S6dBArSEMdfdVQKCtuEAKdUEpILsOcI4S4x3B8z8tlw5DsCdiFgW3M2mijFrKppiPGiKDnN1upeZ6StoaUu-Ijnw_hZIo2vbBXINAe-5p_naJ-6JxAGP_UlCPNtWvIZuIA6qV-kldCDE8kWARn1rnXCqB5Zjg3wwulVjlh5u7__0w.snJwpNse2FWAMfsSA08jSg.4Lngki9KU2RPD0KkchsxkAtAaITJR0Hv_gvL4Rfn3-UOif2kvIGk3iX8G3Xo0gvdem7x0_70EMM0jLgZT-tvxc5gm10lbuD3R1nCefcgJcAD3zBN-UKKbNRVe8VxTWNNQXhK8bAEvphjgR3yhQt6LyxQw2-arjkz947kRPctj9UmRQNItD4oN0KOchEmuGIYhCzi3SKsJxbcembWNPmFU9DKJqFdRotzCy8bMlYBY2Wh744Ue3AhuJeg1YuYd6giQZNowlkuP10O27FwATpfC4iE6Y1rbMFj8ZIXMJn5vwz1gh51n4NCp-qjQOeYCcECV4E3ITrQprIjF2fCo9HlRTNETktx13s31trD1sCpTunYRnLrcCaEJdMDLVsVRXxIrMF4thEFwU3pOkjMHgD27fAGfoLSJfghU59938MuBBW8Zx51NE6Ylza9G0giTb0TVSE0ajaGextQW5DC5hLmbx8kk3-d3WfslYv2nLflwh4JtSZsXDsumnivWG_YXU1SvpL7MUdr9a8kWeUkA6NTQhXVLb-38N-BQBzOiU8TGaf5FQ86B8BEi8z3w-mBwM2hrOR7n1AgmrqmJXM7YbvkUBLZPCZX_F2ekPNItr8HQEbPAVGrftAwyp7gLWSz_7Ysypy_88LZnTTD1Xy9GBtPouveXtedfEMMY6vuDoW2FURwibxwv0PbLxFk96kHUX8imEMaKKMZSyvP1xe93-xyyw4XMdwgckJluU0gviMUtJDbT_Ew1cHzS0yfCVhKawfP0TjAdBeDhAQmhJ6WDzRHSxZ3vs_2yVxbcB7ijqhGsL8YbyBOeBJqUO0tZt-dvZmK0auac24b_yFCClglWUUg3lGuG2OsUNwep6EfYkv__V3ewTOZH0Fr4Xzh5RqU9ChvSGrU1hR4SXjMsfHLnzggPzPionvnNlPQe3QpMLaBsUYDWBiyYHa2EodXwk6lQfU0ur9-BrCoo8iEy_VCTa_epa-6hdQtVHq1MYGfYf2hXZkMBdS_dEgsMjwTUynhAeiqv11VCd6wkttc_atN5kAMWydBwMqMvW9vdwart1CGTFNpi_9moVkZc2nNEUfBLFARey_Tc0Ry4nkCYRzePJ65sY-Bn1u_xdDQPgneoP7locVhqF6G040K0ZKBkQQaUwPO20KbWHlGuPphOk7YW4GPo0eD1k34PlcXjlaNH6VZxW9yUGUsa6BG52eP9SggiowJDGkRzuOknLBRCuaRTopLayPRaSrPu3rnYy3_ggvjGuIQDH4nsikK_SANDA8yJEfl6KQJbRJyNw3nxi8kQgRcjctPfKDmHUsWS0Q7RzoN_4JUzM1mcT74ciBJ26gx72iwAJQYPWzMvazEvgop2Wv4ii8aaHccbkpDaUd85I-gv0G5hZyIVRB_pDLvL5kw0Q2VVsIeBtcX5_RN6e8PJTlNAWxdlRWwe9-vHAnwsIIqi4BlA9-URdj2flUxZwitLzfbPOkFUQ_QcJqUURRnlrmgUwbiYIZrCU76nuyfnuCpm1kkktajGHCq8Re86kjkGU28ZcuZ-uQ1ewIyHyartYNaaFuKZg4MoqGJySvf_gWReAWIC3vvxo5REgvj3h7MkJNI6Q4D0B3HT6MJ5dZpxQHocM_8tfIhFz9tuFsnhbbWcqsCNoDqh7XSkf66sLsBRN4Mg_te9r4HUa9I3o9IEWyyeIfM7GTW3tSUUFwB9hmLYlQDYUgZVO1glHckZQrMVVThZGcJ5hnYJ3UaSG6VvFV6EokTF3fDJgAyzkanqZ8sZMafWg_gqVQ_ldM6ck1rE9hnXs_ZfuJr43_9FotXDljE01TWKiCs1UUjf9G5BjZmenunKCirDmxDeVuEJD9zDQ29Y3XecAGNoL_IZE8uvZIvVog70x-wq1hHWko5UQzhu8DuXHqiW1DVDpEf4UxzEi-DvFVOTfbVP_5f-9Sra_NP5P2WOsXrmsmS9n6IHs2vB10Vrqj5oU0r6sHcnUEs525NbRK16YLSVHrfEeuOJ9pgI9VAmZaBkyqCw7aWAGrWh_WptCtfaBKaCuCOJNdVEyM66a78MyPGZM7a8wEv1655O7HIJKQQcO-jHWi0U7vZx6UB5WxX9nvB9yx8tl9zyD3HMsjBOkla33_3AjVvreJ1mGAvCu_EEEDXnRDQ512bTxqDodE4Epb9Qm5Xbls6my0L-qQIAZGJ6Z8l5RCpOP08q-6oRRHBmPxQ2vrJJnYIxFnXNyR9b31DSBvUQukWO70Fra2fXEhvKdUTja5_jPRq-OfuzfMG5Qb8QRdyRYE4Tne2RMPPyuwJ3O6ufEiao1ptxHalwZDcW-NA8CIphLGHj5_8pT6k0DjKzX_L1l_33c5HNHzUvS362EMon5ycJAxRmU3AbJFae2_Y7IOlnlywyoeRvnPS8cwZVSoNTt_hCH8_YM36KdMfaDqanU28eQky0Vucj8pceoHunSefNg8v58U1aM9YU61ijIUShNcUosDH0UnQYviL-COkSBIQkyMvYbspaVU6ahUZ1Tst3k0Z8plUx-IKVD8inbifieyF_wUh3s-tQJg1_Kwno06H5hlQ3C3eIt1VmjvKdnyuwOTL5eDAlUWu5UBekMxzkMZUhFxHOjSFZytVxhCAcUfw-8aMO0DSUFMWUJ31LvW6X3cN7SfPfxl0sEkz3LAhtBc6AH2ED-hOfAqyu8Ei0sfsuyNsRE_sDIJqb6mny159Msuf7r3_lOTUzLlYtvEb6mRnJkzON-HFAAmFE6yQC_l1x7aPWyJWx7IWFpH8XOMC7c0H9y7Fhnk7yWj6o_VO9zk4xvX_7g1O6rGs0fNXYQPMN75gUcTh24lNtWbcO4P9eV8tDN0PfHjTRqdNYJwL9Xq7hT1aKZbtXxDg0Gw1ZGYorFEiw0y_tsDf_qCo5tOypMtO_AhCYequMzX2BygQ1eyVvn_VQKDzZECjEGOddEchLz9ljeL_7N9i7PzsBAyNOmYu_jZe8dChQwv52TV1uDEQJY_sks9gzSyEbJQS1OgftvdY2ovJ3-tbN6vah7URicJSwL5jgrpZTG779NFb5uywaX6EBGZ03-93VMRfMcK-rrBhwZegx2G8-YcvJPS7xyYA4SvehghlfhBr-FmkIBIukv15sh92AvnXTKExmXhV8RGlMABM9GdAo4XNP9I_xXYZh75ew2jP6l-64MIDh47vVxInlTfPpHJegRGk1PYx9zx18rkEY7TP1OkEzpknijKPKkrV-ttO0pVa7-aNE4v1U5s6HBIQd3S9E2wjEA_mKMDEkedI8kHJNGkzbgZ95Xqm521VGIn-VflOhz1MTR2kCsj5c2E_0Nm0dA9b5qyWPftxq2hauT7C637p-4-T410s8UJkGpZX1XNitCCUKdzTFb1jgC0GZGNUUuZ5fkVnlL9GtAZOOom7xtByTVeoER-8x8vOQxF2PiwcurtNpenl-bm861DoV2_0Tz9VtPa2AQO0YGYPE7AScR-cvAZg5VLgAh_xztg792gTkhsfRoVATBADBOt4VDVQJfhTeH_rfxfSnK96eDANS-lphZAjyK2-tfQ2432B7agl73B-afHUlIJ5SsOLHiZzoNB7X8Q1XbzOny6aBNhGjSSlwk1kIJBwOOoTawXlDbtIh7_9ClhC5TmSNLn4poazG3FMpEA4mJBHzghK9qoCjG7KjDrmLvbWiVghSm0r5E0zO0uZG8Kod0Q1JnMX-4mhxjIbHDvXpWLn3qXJRwoyO_c_JngcK8hVZka2hPlO635AjKYbIJRGcmq07wM_6RS5WJsKvGeDOtuefZWAdruCr14uHMbg-z80NUbHOWjHAbN-fRqt07E8wJV04T4qT0d3VpRIZIrkNIRT7jeXHQ7w0vM3C5czOJGNQj0Mt2l6Cx9ysBmGUv6WtL-HQ587iBIvSmDyqedgGZUbHmuqBQINRgAghpFSzRu5HpmPNA5VrU1z3Q8cjmn5TRKu5C_3Z_ymviGpjWGmgBeDHQs0xlPCN6RQGIJMzdDpBV3eXL8YT7t5YEgul6hH4viET5YbuwlWiUkYXfqSBvjT9-YqBVTJh8KpdBUG05z3TryL-gB2V3wmnQAORydHGsi8lKlKWmC3MTNY1R1yCHAaW--nL7Tk3Ja4KzJsYusDoZi5YfTdzYoEbVnRlOTuKLctopLaSiZZ5b6E0CdwKfXRWVeOOcjpI2ArKOWWMnZX07bd8V3_2JKrPSwz22W2HIjCy2eGZ_RH5dZq25_Ok9SBAAoX63sX2dVvVbEwyoE-oP-uttq5l5Tr1v-bU0OJrfRm3VEHF2pTuUeZtn_Kh9q0F7OCBIw0C54ENYne-cDRSEGW2U9MMZ9EEmtOt_1b4tHZH-SwJAp42737WdaYKDcR8rtdPYsyOeTpm8gtWI2-dx-u3AozWYLBw8Lv6EnnFZ-13QsnIMMw6dKyYPKLw8lGNqWGlbxLGAE76_hKEfBm8flRvl27jY6N7mZt3wWkxfyD9RUyHkFk_uSB8O5vBUms1XfcDUTVJmkMkYYuAMEQeoyMXzroVAPHMYRqIEz43KZX0lwMsASaiUpYZ73In9SL_Xbi1T26Ii0PtgQh6CDVRCGH5l16g-Lv4pQq1gynjqMISn-p4K27ItYnhesQlUQ4rcI0FqPT9Wgkt0w-aBThnfpN1KtXw3sd0zATpAXglFo.IBhh9W88uPJ6wMwuZgf_5kZXKh1ZszjVBdfDSLPeE-Q\",\"scope\":\"openid profile email offline_access ui.read ui.admin ui.preferences.read ui.preferences.write authority:tenants.read authority:tenants.write authority:users.read authority:users.write authority:roles.read authority:roles.write authority:clients.read authority:clients.write authority:tokens.read authority:tokens.revoke authority:branding.read authority:branding.write authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve policy:run policy:activate policy:audit policy:edit policy:operate policy:publish airgap:seal airgap:status:read orch:read orch:operate orch:quota analytics.read advisory:read advisory-ai:view advisory-ai:operate vex:read vexhub:read exceptions:read exceptions:approve aoc:verify findings:read release:read release:write release:publish scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate evidence:read export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit platform.context.read platform.context.write doctor:run doctor:admin ops.health integration:read integration:write integration:operate packs.read packs.write packs.run packs.approve registry.admin timeline:read timeline:write trust:read trust:write trust:admin signer:read signer:sign signer:rotate signer:admin\",\"expiresAtEpochMs\":1776599471290},\"identity\":{\"subject\":\"f945f00811f44f008058268a264ed015\",\"roles\":[],\"idToken\":\"eyJhbGciOiJSUzI1NiIsImtpZCI6IjdLUks4OUw1UTNESkVQR09IV1NPSDNMVDktTVdNWVVJRS1aSU1VX1EiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2F1dGhvcml0eS5zdGVsbGEtb3BzLmxvY2FsLyIsImV4cCI6MTc3NjYwMDk3MiwiaWF0IjoxNzc2NTk3NjcyLCJhdWQiOiJzdGVsbGEtb3BzLXVpIiwic3ViIjoiZjk0NWYwMDgxMWY0NGYwMDgwNTgyNjhhMjY0ZWQwMTUiLCJhenAiOiJzdGVsbGEtb3BzLXVpIiwibm9uY2UiOiI3NzIxM2QzMS0wOTY3LTQ0MTItYjE1Ny1hM2RhN2Q3NDZkNjIiLCJhdF9oYXNoIjoiSElrZ1l3ZGZyc213S1B1ZTdjdW1wZyJ9.A9Mlpx8wxmuVLEl2P2hBYs8TXpgLLsAkfr2ywoOj-XBdgOWjjoKx7Uc0S6JqXZjj0aAEtiFTX0pT5QvWwWUhRzKIk_osSIt5eim0lyY3130O1b-zK4xJxLr98ukT-ikhcchJCxTjwyE_m8Mx3ZIyxj52nrWZmFEhx6is2BFgA8OXZmk6UgMFi2PKYjKP9qy_kz_ZcdpHjyaxgU4nyB8giXPWQjBEjZ3bYdotn5BUMQQ4i3NVT-UeriLZnHDb2D147WL0E7KfzoZznkNpKaCnDUo_09MSVXPKhRXqNaH9DYQQ8pMPCcVMDHxMjUEd5ycvAl8LJVSy32-1l1ix9lgJTQ\"},\"dpopKeyThumbprint\":\"oE_VLfMwaUO1GgDCBKUb__qedb99FPz82TOxT8HJZTQ\",\"issuedAtEpochMs\":1776597671291,\"tenantId\":\"default\",\"scopes\":[\"advisory-ai:operate\",\"advisory-ai:view\",\"advisory:read\",\"airgap:seal\",\"airgap:status:read\",\"analytics.read\",\"aoc:verify\",\"authority.audit.read\",\"authority:branding.read\",\"authority:branding.write\",\"authority:clients.read\",\"authority:clients.write\",\"authority:roles.read\",\"authority:roles.write\",\"authority:tenants.read\",\"authority:tenants.write\",\"authority:tokens.read\",\"authority:tokens.revoke\",\"authority:users.read\",\"authority:users.write\",\"doctor:admin\",\"doctor:run\",\"email\",\"evidence:read\",\"exceptions:approve\",\"exceptions:read\",\"export.admin\",\"export.operator\",\"export.viewer\",\"findings:read\",\"graph:read\",\"integration:operate\",\"integration:read\",\"integration:write\",\"notify.admin\",\"notify.escalate\",\"notify.operator\",\"notify.viewer\",\"offline_access\",\"openid\",\"ops.health\",\"orch:operate\",\"orch:quota\",\"orch:read\",\"packs.approve\",\"packs.read\",\"packs.run\",\"packs.write\",\"platform.context.read\",\"platform.context.write\",\"policy:activate\",\"policy:approve\",\"policy:audit\",\"policy:author\",\"policy:edit\",\"policy:operate\",\"policy:publish\",\"policy:read\",\"policy:review\",\"policy:run\",\"policy:simulate\",\"profile\",\"registry.admin\",\"release:publish\",\"release:read\",\"release:write\",\"sbom:read\",\"scanner:read\",\"scheduler:operate\",\"scheduler:read\",\"signer:admin\",\"signer:read\",\"signer:rotate\",\"signer:sign\",\"timeline:read\",\"timeline:write\",\"trust:admin\",\"trust:read\",\"trust:write\",\"ui.admin\",\"ui.preferences.read\",\"ui.preferences.write\",\"ui.read\",\"vex:read\",\"vexhub:read\",\"vuln:audit\",\"vuln:investigate\",\"vuln:operate\",\"vuln:view\"],\"audiences\":[],\"authenticationTimeEpochMs\":1776597671000,\"freshAuthActive\":false,\"freshAuthExpiresAtEpochMs\":null}" + }, + { + "name": "stellaops.helper.preferences", + "value": "{\"dismissed\":false,\"tooltipsMuted\":false,\"mutedPages\":[],\"mutedTipIds\":[],\"seenPages\":[],\"tipIndex\":{},\"dismissedBanners\":[],\"seenHelpPages\":[],\"pageHelpOpen\":{},\"pageHelpDismissedGlobal\":false,\"pageHelpDismissedPages\":[]}" + }, + { + "name": "stellaops.content-width", + "value": "centered" + }, + { + "name": "stellaops.assistant.state", + "value": "{\"seenRoutes\":[],\"completedTours\":[],\"tipPositions\":{},\"dismissed\":false}" + }, + { + "name": "stellaops.theme", + "value": "system" + }, + { + "name": "stellaops.auth.session.info", + "value": "{\"subject\":\"f945f00811f44f008058268a264ed015\",\"expiresAtEpochMs\":1776599471290,\"issuedAtEpochMs\":1776597671291,\"dpopKeyThumbprint\":\"oE_VLfMwaUO1GgDCBKUb__qedb99FPz82TOxT8HJZTQ\",\"tenantId\":\"default\"}" + }, + { + "name": "stellaops.sidebar.preferences", + "value": "{\"sidebarCollapsed\":false,\"collapsedGroups\":[\"evidence\",\"setup-admin\"],\"collapsedSections\":[]}" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/Web/StellaOps.Web/scripts/live-setup-wizard-first-run-bootstrap.mjs b/src/Web/StellaOps.Web/scripts/live-setup-wizard-first-run-bootstrap.mjs new file mode 100644 index 000000000..edf1aa316 --- /dev/null +++ b/src/Web/StellaOps.Web/scripts/live-setup-wizard-first-run-bootstrap.mjs @@ -0,0 +1,693 @@ +#!/usr/bin/env node + +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { chromium } from 'playwright'; + +import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const webRoot = path.resolve(__dirname, '..'); +const outputDir = path.join(webRoot, 'output', 'playwright'); +const outputPath = path.join(outputDir, 'live-setup-wizard-first-run-bootstrap.json'); +const authStatePath = path.join(outputDir, 'live-setup-wizard-first-run-bootstrap.state.json'); +const authReportPath = path.join(outputDir, 'live-setup-wizard-first-run-bootstrap.auth.json'); + +const baseUrl = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local'; +const wizardUrl = `${baseUrl}/setup-wizard/wizard`; +const adminUsername = process.env.STELLAOPS_ADMIN_USER?.trim() || 'admin'; +const adminEmail = process.env.STELLAOPS_ADMIN_EMAIL?.trim() || 'admin@stella-ops.local'; +const adminPassword = process.env.STELLAOPS_ADMIN_PASS?.trim(); + +if (!adminPassword) { + throw new Error('Set STELLAOPS_ADMIN_PASS before running the live first-run setup bootstrap.'); +} +const headless = (process.env.STELLAOPS_UI_BOOTSTRAP_HEADLESS || 'true').toLowerCase() !== 'false'; + +function isStaticAsset(url) { + return /\.(?:css|js|map|png|jpg|jpeg|svg|woff2?|ico)(?:$|\?)/i.test(url); +} + +function cleanText(value) { + return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : ''; +} + +function normalizeStepId(value) { + switch ((value ?? '').toString().trim().toLowerCase()) { + case 'database': + return 'database'; + case 'valkey': + case 'cache': + return 'cache'; + case 'migrations': + return 'migrations'; + case 'admin': + return 'admin'; + case 'crypto': + return 'crypto'; + case 'sources': + return 'sources'; + default: + return (value ?? '').toString().trim().toLowerCase(); + } +} + +function normalizeStepStatus(value) { + switch ((value ?? '').toString().trim().toLowerCase()) { + case 'inprogress': + return 'in_progress'; + case 'pass': + case 'passed': + return 'completed'; + default: + return (value ?? '').toString().trim().toLowerCase(); + } +} + +function createRuntime() { + return { + consoleErrors: [], + pageErrors: [], + requestFailures: [], + responseErrors: [], + setupApiEvents: [], + }; +} + +function isExpectedAnonymousSetupCreate401(event, runtime) { + return event?.method === 'POST' + && event?.status === 401 + && event?.url === `${baseUrl}/api/v1/setup/sessions` + && runtime.setupApiEvents.some((candidate) => + candidate.method === 'POST' + && candidate.status === 201 + && candidate.url === `${baseUrl}/api/v1/setup/sessions`); +} + +function isAuthenticatedWizardSurface(bodyText, runtime) { + return /PostgreSQL Connection|Valkey\/Redis|Authority Administrator|Apply and Continue|Validate Connection/i.test(bodyText) + || runtime.setupApiEvents.some((event) => + event.method === 'POST' + && event.status === 201 + && event.url === `${baseUrl}/api/v1/setup/sessions`); +} + +function attachRuntime(page, runtime) { + page.on('console', (message) => { + if ( + message.type() === 'error' + && !message.text().startsWith('Failed to load resource: the server responded with a status of') + ) { + runtime.consoleErrors.push({ page: page.url(), text: message.text() }); + } + }); + + page.on('pageerror', (error) => { + runtime.pageErrors.push({ + page: page.url(), + text: error instanceof Error ? error.message : String(error), + }); + }); + + page.on('requestfailed', (request) => { + const errorText = request.failure()?.errorText ?? 'unknown'; + if ( + isStaticAsset(request.url()) + || errorText === 'net::ERR_ABORTED' + || (!request.url().includes('/api/v1/setup') && !request.url().includes('/setup-wizard')) + ) { + return; + } + + runtime.requestFailures.push({ + page: page.url(), + method: request.method(), + url: request.url(), + error: errorText, + }); + }); + + page.on('response', (response) => { + if ( + isStaticAsset(response.url()) + || response.url().includes('/api/v1/stella-assistant/tips') + || (!response.url().includes('/api/v1/setup') && !response.url().includes('/setup-wizard')) + ) { + return; + } + + if (response.url().includes('/api/v1/setup/')) { + runtime.setupApiEvents.push({ + page: page.url(), + method: response.request().method(), + status: response.status(), + url: response.url(), + }); + } + + if (response.status() >= 400) { + runtime.responseErrors.push({ + page: page.url(), + method: response.request().method(), + status: response.status(), + url: response.url(), + }); + } + }); +} + +async function settle(page, ms = 1500) { + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }).catch(() => {}); + await page.waitForTimeout(ms); +} + +async function waitForBodyText(page, text, timeout = 60_000) { + await page.waitForFunction( + (expected) => (document.body?.innerText || '').replace(/\s+/g, ' ').includes(expected), + text, + { timeout }, + ); + await page.waitForTimeout(1000); +} + +async function captureSnapshot(page, label) { + const alerts = await page + .locator('[role="alert"], .error-banner, .warning-banner, .banner, .toast, .notification, .status-card, .test-result') + .evaluateAll((nodes) => + nodes + .map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim()) + .filter(Boolean) + .slice(0, 10), + ) + .catch(() => []); + + const visibleButtons = await page + .locator('button') + .evaluateAll((nodes) => + nodes + .map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim()) + .filter(Boolean) + .slice(0, 12), + ) + .catch(() => []); + + return { + label, + url: page.url(), + title: await page.title().catch(() => ''), + heading: cleanText(await page.locator('h1, h2').first().textContent().catch(() => '')), + alerts, + visibleButtons, + }; +} + +async function fetchJson(page, relativeUrl, options = {}) { + const response = await page.request.fetch(`${baseUrl}${relativeUrl}`, { + method: options.method ?? 'GET', + failOnStatusCode: false, + headers: { + Accept: 'application/json', + ...(options.body ? { 'Content-Type': 'application/json' } : {}), + }, + data: options.body ?? undefined, + }); + + const bodyText = await response.text().catch(() => ''); + let payload = null; + try { + payload = JSON.parse(bodyText); + } catch { + payload = null; + } + + return { + status: response.status(), + ok: response.ok(), + payload, + bodyText: bodyText.slice(0, 4000), + }; +} + +async function readCurrentSession(page) { + const result = await fetchJson(page, '/api/v1/setup/sessions/current'); + const session = result?.payload?.session ?? null; + + return { + status: result.status, + ok: result.ok, + sessionId: session?.sessionId ?? null, + currentStepId: normalizeStepId(session?.currentStepId ?? null), + sessionStatus: normalizeStepStatus(session?.status ?? null), + completedAtUtc: session?.completedAtUtc ?? null, + steps: Array.isArray(session?.steps) + ? session.steps.map((step) => ({ + stepId: normalizeStepId(step.stepId), + status: normalizeStepStatus(step.status), + lastProbeSucceeded: step.lastProbeSucceeded ?? null, + errorMessage: step.errorMessage ?? null, + })) + : [], + raw: result, + }; +} + +async function createOrResumeSetupSession(page) { + const resumed = await fetchJson(page, '/api/v1/setup/sessions/resume', { method: 'POST' }); + if (resumed.ok) { + return resumed; + } + + return fetchJson(page, '/api/v1/setup/sessions', { + method: 'POST', + body: {}, + }); +} + +async function waitForSession(page, predicate, timeoutMs = 60_000) { + const deadline = Date.now() + timeoutMs; + let lastSnapshot = null; + + while (Date.now() < deadline) { + lastSnapshot = await readCurrentSession(page); + if (predicate(lastSnapshot)) { + return lastSnapshot; + } + + await page.waitForTimeout(1000); + } + + throw new Error(`Timed out waiting for setup session condition. Last snapshot: ${JSON.stringify(lastSnapshot)}`); +} + +function stepStatus(sessionSnapshot, stepId) { + return sessionSnapshot.steps.find((step) => step.stepId === stepId) ?? null; +} + +function sessionReadyForFinalize(sessionSnapshot) { + if (!sessionSnapshot?.ok) { + return false; + } + + if (sessionSnapshot.currentStepId) { + return false; + } + + const requiredSteps = ['database', 'cache', 'migrations', 'admin', 'crypto']; + const allRequiredStepsCompleted = requiredSteps.every( + (stepId) => stepStatus(sessionSnapshot, stepId)?.status === 'completed', + ); + + return allRequiredStepsCompleted && sessionSnapshot.raw?.payload?.readiness?.readyToProceed === true; +} + +async function waitForCurrentStep(page, expectedStepId) { + await waitForSession(page, (snapshot) => snapshot.ok && snapshot.currentStepId === expectedStepId, 45_000); + await page.waitForTimeout(500); +} + +async function ensureFieldValue(page, selectors, value) { + const locator = page.locator(selectors.join(', ')).first(); + await locator.waitFor({ state: 'visible', timeout: 30_000 }); + await locator.fill(value); + await page.waitForTimeout(150); +} + +async function chooseDefaultCryptoProvider(page) { + const target = page.getByRole('button', { name: /Default \(Recommended\)/i }).first(); + if (!(await target.isVisible().catch(() => false))) { + return false; + } + + await target.click({ timeout: 10_000 }); + await page.waitForTimeout(300); + return true; +} + +async function applyStep(page, currentStepId, nextStepId, expectedTextAfter) { + await Promise.all([ + page.waitForResponse( + (response) => + response.request().method() === 'POST' + && response.url().includes('/api/v1/setup/sessions/') + && response.url().includes(`/steps/${currentStepId}/apply`), + { timeout: 45_000 }, + ), + page.getByRole('button', { name: /Apply and Continue/i }).first().click({ timeout: 15_000 }), + ]); + + if (nextStepId) { + await waitForCurrentStep(page, nextStepId); + } + + if (expectedTextAfter) { + await waitForBodyText(page, expectedTextAfter, 45_000); + } +} + +async function finalizeSetup(page, currentStepId = 'crypto') { + const finishButton = page.getByRole('button', { name: /Finish Setup/i }).first(); + await finishButton.waitFor({ state: 'visible', timeout: 20_000 }); + + const currentApplyResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' + && response.url().includes('/api/v1/setup/sessions/') + && response.url().includes(`/steps/${currentStepId}/apply`), + { timeout: 10_000 }, + ).catch(() => null); + + const finalizeResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' + && response.url().includes('/api/v1/setup/sessions/') + && response.url().includes('/finalize'), + { timeout: 60_000 }, + ); + + await finishButton.click({ timeout: 15_000 }); + + const finalizeResponse = await finalizeResponsePromise; + const currentApplyResponse = await currentApplyResponsePromise; + + const finalizeBody = await finalizeResponse.json().catch(() => null); + if (!finalizeBody?.data?.success) { + throw new Error(`Finalize returned non-success payload: ${JSON.stringify(finalizeBody)}`); + } + + const sessionClosure = await page.request.get(`${baseUrl}/api/v1/setup/sessions/current`, { + failOnStatusCode: false, + }); + + const sessionClosureText = await sessionClosure.text().catch(() => ''); + let sessionClosureJson = null; + try { + sessionClosureJson = JSON.parse(sessionClosureText); + } catch { + sessionClosureJson = null; + } + + await Promise.race([ + page.waitForURL((url) => !url.toString().includes('/setup-wizard/'), { timeout: 30_000 }).catch(() => {}), + page.waitForTimeout(5_000), + ]); + + if (page.url().includes('/setup-wizard/')) { + await page.goto(`${baseUrl}/welcome`, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch(() => {}); + } + + await settle(page, 2000); + + return { + currentApplyStepId: currentStepId, + currentApplyStatus: currentApplyResponse?.status() ?? null, + finalizeStatus: finalizeResponse.status(), + finalizeBody, + sessionAfterFinalize: { + status: sessionClosure.status(), + ok: sessionClosure.ok(), + payload: sessionClosureJson, + bodyText: sessionClosureText.slice(0, 4000), + }, + }; +} + +async function authenticateAfterSetup(browser, runtime) { + const authReport = await authenticateFrontdoor({ + baseUrl, + username: adminUsername, + password: adminPassword, + statePath: authStatePath, + reportPath: authReportPath, + headless, + }); + + const context = await createAuthenticatedContext(browser, authReport, { statePath: authStatePath }); + const page = await context.newPage(); + attachRuntime(page, runtime); + + return { authReport, context, page }; +} + +async function main() { + await mkdir(outputDir, { recursive: true }); + + const browser = await chromium.launch({ + headless, + args: ['--ignore-certificate-errors', '--disable-dev-shm-usage'], + }); + + let context = await browser.newContext({ ignoreHTTPSErrors: true }); + let page = await context.newPage(); + const runtime = createRuntime(); + attachRuntime(page, runtime); + + const steps = []; + let authReport = null; + + await page.goto(wizardUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + await settle(page, 3000); + const startSetupButton = page.getByRole('button', { name: 'Start Setup', exact: true }); + let initialSession = await readCurrentSession(page); + const initialSnapshot = await captureSnapshot(page, 'initial'); + const initialReadyForFinalize = sessionReadyForFinalize(initialSession); + + if (initialSession.status === 401) { + ({ authReport, context, page } = await authenticateAfterSetup(browser, runtime)); + await page.goto(wizardUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + await settle(page, 3000); + + await Promise.race([ + waitForBodyText(page, 'Setup already completed', 15_000).catch(() => {}), + page.waitForFunction( + () => /PostgreSQL Connection|Valkey\/Redis|Authority Administrator|Apply and Continue|Validate Connection/i.test( + (document.body?.innerText || '').replace(/\s+/g, ' '), + ), + null, + { timeout: 15_000 }, + ).catch(() => {}), + page.waitForTimeout(15_000), + ]); + + const authenticatedSession = await readCurrentSession(page); + const configuredSnapshot = await captureSnapshot(page, 'setup-already-complete'); + const configuredBodyText = await page.locator('body').innerText().catch(() => ''); + const setupAlreadyComplete = /setup already completed/i.test(configuredBodyText); + const authenticatedWizardActive = isAuthenticatedWizardSurface(configuredBodyText, runtime); + steps.push({ + action: 'setup-entry-after-authentication', + ok: + authReport.sessionStatus?.hasFullSession === true + && (setupAlreadyComplete || authenticatedWizardActive), + resolvedSurface: setupAlreadyComplete ? 'already-complete' : authenticatedWizardActive ? 'authenticated-reconfigure' : 'unknown', + initialAnonymousSession: initialSession, + authenticatedSetupSessionProbe: authenticatedSession, + initialSnapshot, + snapshot: configuredSnapshot, + }); + + await context.close(); + await browser.close(); + + const summary = { + generatedAtUtc: new Date().toISOString(), + baseUrl, + adminUsername, + adminEmail, + mode: 'already-configured', + steps, + runtime, + postSetupAuthentication: { + finalUrl: authReport.finalUrl, + title: authReport.title, + reportPath: authReportPath, + statePath: authStatePath, + }, + }; + + await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8'); + + const failedSteps = steps.filter((step) => step.ok === false); + const unexpectedResponseErrors = runtime.responseErrors.filter( + (event) => !isExpectedAnonymousSetupCreate401(event, runtime), + ); + const runtimeIssues = + runtime.consoleErrors.length + + runtime.pageErrors.length + + runtime.requestFailures.length + + unexpectedResponseErrors.length; + + if (failedSteps.length > 0 || runtimeIssues > 0) { + process.exitCode = 1; + } + + return; + } + + if (!initialSession.ok || (!initialSession.currentStepId && !initialReadyForFinalize)) { + await createOrResumeSetupSession(page); + initialSession = await waitForSession(page, (snapshot) => snapshot.ok && snapshot.currentStepId === 'database'); + await page.reload({ waitUntil: 'domcontentloaded', timeout: 60_000 }); + await settle(page, 3000); + } + + if (sessionReadyForFinalize(initialSession)) { + steps.push({ + action: 'resume-ready-for-finalize', + ok: true, + session: initialSession, + snapshot: initialSnapshot, + }); + } else if (await startSetupButton.isVisible().catch(() => false)) { + steps.push({ + action: 'welcome', + ok: initialSession.status === 200 && initialSession.currentStepId === 'database', + session: initialSession, + snapshot: initialSnapshot, + }); + + await startSetupButton.click({ timeout: 20_000 }); + await waitForBodyText(page, 'PostgreSQL Connection'); + } else if (initialSession.currentStepId === 'database') { + steps.push({ + action: 'database-direct-open', + ok: true, + session: initialSession, + snapshot: initialSnapshot, + }); + } else { + throw new Error(`Setup wizard opened in an unexpected state: ${JSON.stringify({ + url: page.url(), + currentStepId: initialSession.currentStepId, + sessionStatus: initialSession.sessionStatus, + snapshot: initialSnapshot, + })}`); + } + + if (!sessionReadyForFinalize(initialSession)) { + steps.push({ + action: 'database-open', + ok: (await readCurrentSession(page)).currentStepId === 'database', + snapshot: await captureSnapshot(page, 'database-open'), + }); + + await applyStep(page, 'database', 'cache', 'Valkey/Redis Connection'); + const afterDatabase = await readCurrentSession(page); + steps.push({ + action: 'database-applied', + ok: afterDatabase.currentStepId === 'cache' && stepStatus(afterDatabase, 'database')?.status === 'completed', + session: afterDatabase, + snapshot: await captureSnapshot(page, 'database-applied'), + }); + + await applyStep(page, 'cache', 'migrations', 'Database Migrations'); + const afterCache = await readCurrentSession(page); + steps.push({ + action: 'cache-applied', + ok: afterCache.currentStepId === 'migrations' && stepStatus(afterCache, 'cache')?.status === 'completed', + session: afterCache, + snapshot: await captureSnapshot(page, 'cache-applied'), + }); + + await applyStep(page, 'migrations', 'admin', 'Super User Account'); + const afterMigrations = await readCurrentSession(page); + steps.push({ + action: 'migrations-applied', + ok: afterMigrations.currentStepId === 'admin' && stepStatus(afterMigrations, 'migrations')?.status === 'completed', + session: afterMigrations, + snapshot: await captureSnapshot(page, 'migrations-applied'), + }); + + await ensureFieldValue(page, ['#users-superuser-username', 'input[name="users.superuser.username"]'], adminUsername); + await ensureFieldValue(page, ['#users-superuser-email', 'input[name="users.superuser.email"]'], adminEmail); + await ensureFieldValue(page, ['#users-superuser-password', 'input[name="users.superuser.password"]'], adminPassword); + + await applyStep(page, 'admin', 'crypto', 'Cryptographic Provider'); + const afterAdmin = await readCurrentSession(page); + steps.push({ + action: 'admin-applied', + ok: afterAdmin.currentStepId === 'crypto' && stepStatus(afterAdmin, 'admin')?.status === 'completed', + session: afterAdmin, + snapshot: await captureSnapshot(page, 'admin-applied'), + }); + } + + let cryptoSelectionApplied = false; + let beforeFinalizeSession = await readCurrentSession(page); + + if (beforeFinalizeSession.currentStepId === 'crypto') { + cryptoSelectionApplied = await chooseDefaultCryptoProvider(page); + await applyStep(page, 'crypto', 'sources', 'Advisory Data Sources'); + const afterCrypto = await readCurrentSession(page); + steps.push({ + action: 'crypto-applied', + ok: afterCrypto.currentStepId === 'sources' && stepStatus(afterCrypto, 'crypto')?.status === 'completed', + session: afterCrypto, + snapshot: await captureSnapshot(page, 'crypto-applied'), + }); + beforeFinalizeSession = afterCrypto; + } + + const completion = await finalizeSetup(page, beforeFinalizeSession.currentStepId || 'sources'); + steps.push({ + action: 'setup-finished', + ok: completion.finalizeBody?.data?.success === true, + cryptoSelectionApplied, + completion, + snapshot: await captureSnapshot(page, 'setup-finished'), + }); + + await context.close(); + + authReport ??= await authenticateFrontdoor({ + baseUrl, + username: adminUsername, + password: adminPassword, + statePath: authStatePath, + reportPath: authReportPath, + headless, + }); + + await browser.close(); + + const summary = { + generatedAtUtc: new Date().toISOString(), + baseUrl, + adminUsername, + adminEmail, + steps, + runtime, + postSetupAuthentication: { + finalUrl: authReport.finalUrl, + title: authReport.title, + reportPath: authReportPath, + statePath: authStatePath, + }, + }; + + await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8'); + + const failedSteps = steps.filter((step) => step.ok === false); + const runtimeIssues = + runtime.consoleErrors.length + + runtime.pageErrors.length + + runtime.requestFailures.length + + runtime.responseErrors.length; + + if (failedSteps.length > 0 || runtimeIssues > 0) { + process.exitCode = 1; + } +} + +main().catch(async (error) => { + await mkdir(outputDir, { recursive: true }); + const summary = { + generatedAtUtc: new Date().toISOString(), + fatalError: error instanceof Error ? error.message : String(error), + }; + await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8'); + console.error(error); + process.exitCode = 1; +}); diff --git a/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/advisory-source-catalog.component.ts b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/advisory-source-catalog.component.ts index aa97cf441..5c3f05e11 100644 --- a/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/advisory-source-catalog.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/advisory-source-catalog.component.ts @@ -57,6 +57,12 @@ interface CategoryGroup { items: SourceCatalogItem[]; } +interface EnabledSourceFailureSummary { + sourceId: string; + displayName: string; + error: string; +} + @Component({ selector: 'app-advisory-source-catalog', standalone: true, @@ -128,6 +134,31 @@ interface CategoryGroup { @if (loading()) { } @else { + @if (enabledCount() === 0) { + + } + @if (enabledSourceFailures().length > 0) { + + } +
{{ enabledCount() }} enabled {{ healthyCount() }} healthy @@ -1166,6 +1197,54 @@ export class AdvisorySourceCatalogComponent implements OnInit { return count; }); + readonly enabledSourceFailures = computed((): EnabledSourceFailureSummary[] => { + const failures: EnabledSourceFailureSummary[] = []; + const metrics = this.metrics(); + const catalogNames = new Map(this.catalog().map((item) => [item.id, item.displayName])); + + this.statuses().forEach((status, sourceId) => { + if (!status.enabled) { + return; + } + + const metric = metrics.get(sourceId); + const error = + metric?.lastError?.trim() + || ((status.lastCheck && !status.lastCheck.isHealthy) + ? (status.lastCheck.errorMessage?.trim() || `Connectivity check reported ${status.lastCheck.status}.`) + : null); + + if (!error) { + return; + } + + failures.push({ + sourceId, + displayName: catalogNames.get(sourceId) ?? sourceId, + error, + }); + }); + + return failures.sort((left, right) => left.displayName.localeCompare(right.displayName)); + }); + + readonly enabledSourceFailureMessage = computed(() => { + const failures = this.enabledSourceFailures(); + if (failures.length === 0) { + return ''; + } + + const [first, ...rest] = failures; + if (rest.length === 0) { + return `${first.displayName}: ${first.error}`; + } + + return `${first.displayName}: ${first.error} (+${rest.length} more enabled source error${rest.length === 1 ? '' : 's'}).`; + }); + + readonly hasMirrorFailure = computed(() => + this.enabledSourceFailures().some((failure) => failure.sourceId === 'stella-mirror')); + getSourceMetric(sourceId: string): AdvisorySourceMetrics | undefined { return this.metrics().get(sourceId); } @@ -1282,6 +1361,10 @@ export class AdvisorySourceCatalogComponent implements OnInit { : this.api.enableSource(sourceId); op$.pipe(take(1)).subscribe({ + next: () => { + this.reloadStatus(); + this.reloadMetrics(); + }, error: () => { // Revert optimistic update this.statuses.update((current) => { @@ -1296,6 +1379,14 @@ export class AdvisorySourceCatalogComponent implements OnInit { }); } + enableDefaultMirror(): void { + if (this.isSourceEnabled('stella-mirror')) { + return; + } + + this.toggleSourceEnabled('stella-mirror'); + } + onCheckAll(): void { const items = this.catalog(); const enabledIds = items @@ -1560,16 +1651,7 @@ export class AdvisorySourceCatalogComponent implements OnInit { }); // Load advisory source metrics (non-blocking — enriches display with advisory counts) - this.api.getSourceMetrics(true).pipe(take(1)).subscribe({ - next: (resp) => { - const metricsMap = new Map(); - for (const item of resp.items ?? []) { - metricsMap.set(item.sourceKey, item); - } - this.metrics.set(metricsMap); - }, - error: () => { /* Metrics API may be slow on cold start; silently ignore */ }, - }); + this.reloadMetrics(); // Load mirror context in parallel (non-blocking; errors are silently ignored) this.loadMirrorContext(); @@ -1598,4 +1680,17 @@ export class AdvisorySourceCatalogComponent implements OnInit { }, }); } + + private reloadMetrics(): void { + this.api.getSourceMetrics(true).pipe(take(1)).subscribe({ + next: (resp) => { + const metricsMap = new Map(); + for (const item of resp.items ?? []) { + metricsMap.set(item.sourceKey, item); + } + this.metrics.set(metricsMap); + }, + error: () => { /* Metrics API may be slow on cold start; silently ignore */ }, + }); + } } diff --git a/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/setup-wizard.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/setup-wizard.component.spec.ts index eb1f160d9..eb96f623f 100644 --- a/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/setup-wizard.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/setup-wizard.component.spec.ts @@ -1,4 +1,4 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { provideRouter, Router } from '@angular/router'; import { of, throwError } from 'rxjs'; @@ -68,6 +68,18 @@ describe('SetupWizardComponent', () => { ], }; + const completedSessionWithoutCurrentStep: SetupSession = { + ...freshSession, + completedSteps: ['database', 'cache', 'migrations', 'admin', 'crypto'], + steps: [ + { stepId: 'database', status: 'completed' }, + { stepId: 'cache', status: 'completed' }, + { stepId: 'migrations', status: 'completed' }, + { stepId: 'admin', status: 'completed' }, + { stepId: 'crypto', status: 'completed' }, + ], + }; + const cryptoPendingSession: SetupSession = { ...freshSession, completedSteps: ['database', 'cache', 'migrations', 'admin'], @@ -140,6 +152,11 @@ describe('SetupWizardComponent', () => { router = TestBed.inject(Router); }); + afterEach(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + it('initializes fresh sessions on the welcome step', () => { fixture.detectChanges(); @@ -158,6 +175,38 @@ describe('SetupWizardComponent', () => { expect(apiService.runValidationChecks).toHaveBeenCalledWith('test-session-123', 'database'); }); + it('shows the already-configured state after repeated unauthorized bootstrap attempts with a persisted auth session', fakeAsync(() => { + localStorage.setItem('stellaops.auth.session.full', '{"tokens":{"accessToken":"token","expiresAtEpochMs":9999999999999,"tokenType":"Bearer","scope":"ui.read"},"identity":{"subject":"admin","roles":[]},"dpopKeyThumbprint":"thumb","issuedAtEpochMs":1,"tenantId":"default","scopes":["ui.read"],"audiences":[],"authenticationTimeEpochMs":1,"freshAuthActive":false,"freshAuthExpiresAtEpochMs":null}'); + apiService.createSession.and.returnValue(throwError(() => ({ + code: 'UNAUTHORIZED', + message: 'Unauthorized', + status: 401, + retryable: false, + }))); + + fixture.detectChanges(); + tick(1600); + fixture.detectChanges(); + + expect(apiService.createSession).toHaveBeenCalledTimes(3); + expect(component.setupAlreadyConfigured()).toBeTrue(); + expect(fixture.nativeElement.textContent).toContain('Setup already completed'); + })); + + it('surfaces the backend error when unauthorized bootstrap is not backed by a persisted auth session', () => { + apiService.createSession.and.returnValue(throwError(() => ({ + code: 'UNAUTHORIZED', + message: 'Unauthorized', + status: 401, + retryable: false, + }))); + + fixture.detectChanges(); + + expect(component.setupAlreadyConfigured()).toBeFalse(); + expect(stateService.error()).toBe('Unauthorized'); + }); + it('moves to cache when the current database step is already completed', () => { apiService.createSession.and.returnValue(of(databaseSession)); @@ -218,6 +267,14 @@ describe('SetupWizardComponent', () => { expect(router.navigate).toHaveBeenCalledWith(['/']); }); + it('does not reset a progressed session to welcome when backend currentStep is absent', () => { + apiService.createSession.and.returnValue(of(completedSessionWithoutCurrentStep)); + + fixture.detectChanges(); + + expect(stateService.currentStepId()).toBe('crypto'); + }); + it('applies the last pending required step before finalizing the installation', () => { spyOn(router, 'navigate'); apiService.createSession.and.returnValue(of(cryptoPendingSession)); diff --git a/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/setup-wizard.component.ts b/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/setup-wizard.component.ts index 2c5e4883c..66d1fe3f9 100644 --- a/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/setup-wizard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/setup-wizard.component.ts @@ -23,7 +23,7 @@ import { } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { SetupWizardStateService } from '../services/setup-wizard-state.service'; import { SetupWizardApiService } from '../services/setup-wizard-api.service'; @@ -34,12 +34,15 @@ import { SetupSession, } from '../models/setup-wizard.models'; import { DoctorRecheckService } from '../../doctor/services/doctor-recheck.service'; +import { FULL_SESSION_STORAGE_KEY, SESSION_STORAGE_KEY } from '../../../core/auth/auth-session.model'; +import { SetupApiError } from '../services/setup-wizard-api.service'; @Component({ selector: 'app-setup-wizard', imports: [ FormsModule, ReactiveFormsModule, + RouterLink, StepContentComponent ], providers: [SetupWizardStateService, SetupWizardApiService], @@ -129,7 +132,50 @@ import { DoctorRecheckService } from '../../doctor/services/doctor-recheck.servi } - @if (state.currentStepId() === 'welcome') { + @if (setupAlreadyConfigured()) { +
+
+
+ +
+

Setup already completed

+

+ This installation has already finished its first-run bootstrap. + Use the dashboard and Settings surfaces to reconfigure specific areas instead of restarting setup. +

+ +
+
+ + + + Operator authentication is already available for this installation. +
+
+ + + + Use Integrations, Identity, and Certificates to adjust platform configuration. +
+
+ + +
+
+ } @else if (state.currentStepId() === 'welcome') {
@@ -363,6 +409,7 @@ export class SetupWizardComponent implements OnInit, OnDestroy { readonly edgeKey = signal(0); readonly errorExpanded = signal(false); readonly testResult = signal<{ success: boolean; message: string } | null>(null); + readonly setupAlreadyConfigured = signal(false); private edgeTimerId: ReturnType | null = null; private draftSaveTimerId: ReturnType | null = null; @@ -959,11 +1006,12 @@ export class SetupWizardComponent implements OnInit, OnDestroy { // ── Business logic (unchanged) ── private readonly validStepIds: ReadonlySet = new Set([ - 'welcome', 'database', 'cache', 'migrations', 'admin', 'crypto', + 'welcome', 'database', 'cache', 'migrations', 'admin', 'crypto', 'sources', ]); private initializeWizard(): void { this.state.loading.set(true); + this.setupAlreadyConfigured.set(false); // Support deep-link from Doctor "Fix in Setup" button const stepParam = this.route.snapshot.queryParamMap.get('step'); @@ -979,27 +1027,74 @@ export class SetupWizardComponent implements OnInit, OnDestroy { ? (resumeStep as SetupStepId) : null; + this.initializeSession(resumeStepId, true); + } + + private initializeSession( + resumeStepId: SetupStepId | null, + showWelcomeIfFresh: boolean, + attempt = 0, + ): void { this.api.createSession().subscribe({ next: (session) => { - this.applySession(session, resumeStepId, true); + this.setupAlreadyConfigured.set(false); + this.applySession(session, resumeStepId, showWelcomeIfFresh); }, error: (err) => { + if (this.isUnauthorizedSetupError(err) && this.hasPersistedAuthSession()) { + if (attempt < 2) { + setTimeout(() => this.initializeSession(resumeStepId, showWelcomeIfFresh, attempt + 1), 750); + return; + } + + this.state.session.set(null); + this.state.currentStepId.set(null); + this.state.error.set(null); + this.state.loading.set(false); + this.setupAlreadyConfigured.set(true); + return; + } + this.state.error.set(err?.message ?? 'Failed to initialize setup wizard'); this.state.loading.set(false); }, }); } + private isUnauthorizedSetupError(error: unknown): boolean { + const candidate = error as Partial | null | undefined; + return candidate?.status === 401 + || candidate?.code === 'HTTP_401' + || candidate?.code === 'UNAUTHORIZED'; + } + + private hasPersistedAuthSession(): boolean { + try { + return Boolean( + localStorage.getItem(FULL_SESSION_STORAGE_KEY) + || sessionStorage.getItem(FULL_SESSION_STORAGE_KEY) + || localStorage.getItem(SESSION_STORAGE_KEY) + || sessionStorage.getItem(SESSION_STORAGE_KEY), + ); + } catch { + return false; + } + } + private applySession( session: SetupSession, resumeStepId: SetupStepId | null, showWelcomeIfFresh: boolean, ): void { this.state.initializeSession(session); + const hasProgress = + (session.completedSteps?.length ?? 0) > 0 + || (session.skippedSteps?.length ?? 0) > 0 + || session.steps.some(step => step.status !== 'pending'); if (resumeStepId) { this.state.goToStep(resumeStepId); - } else if (showWelcomeIfFresh && (!session.currentStep || (session.completedSteps?.length ?? 0) === 0)) { + } else if (showWelcomeIfFresh && !session.currentStep && !hasProgress) { // First-time setup: start at welcome this.state.currentStepId.set('welcome'); } diff --git a/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/step-content.component.ts b/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/step-content.component.ts index 463cc86db..3ee2bd421 100644 --- a/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/step-content.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/step-content.component.ts @@ -51,7 +51,6 @@ const LOCAL_ADMIN_DEFAULTS: Record = { ...LOCAL_AUTHORITY_DEFAULTS, 'users.superuser.username': 'admin', 'users.superuser.email': 'admin@stella-ops.local', - 'users.superuser.password': 'Admin@Stella1', }; export const SETUP_STEP_LOCAL_DEFAULTS: Record> = { @@ -72,13 +71,13 @@ export const SETUP_STEP_LOCAL_DEFAULTS: Record> = users: { 'users.superuser.username': 'admin', 'users.superuser.email': 'admin@stella-ops.local', - 'users.superuser.password': 'Admin@Stella1', }, crypto: { 'crypto.provider': 'default', }, sources: { - 'sources.mode': 'custom', + 'sources.mode': 'mirror', + 'sources.mirror.url': 'https://mirror.stella-ops.org', }, telemetry: { 'telemetry.otlpEndpoint': 'http://localhost:4317', @@ -89,6 +88,7 @@ export const SETUP_STEP_LOCAL_DEFAULTS: Record> = function isSensitiveConfigKey(key: string): boolean { const normalized = key.trim().toLowerCase(); return normalized.includes('password') + || normalized.includes('apikey') || normalized.includes('secret') || normalized.includes('token') || normalized.includes('privatekey') @@ -318,9 +318,9 @@ export function mergeSetupStepLocalDefaults( @if (hasRetainedSecret('users.superuser.password') && !getConfigValue('users.superuser.password')) { Password retained securely on the server. Leave the field untouched to keep it, or type a new password to replace it. @@ -1438,7 +1438,7 @@ export function mergeSetupStepLocalDefaults(

Advisory Data Sources

-

Choose how vulnerability and VEX advisory data is delivered to your instance.

+

Choose how vulnerability and VEX advisory data is delivered to your instance. You can skip this step and turn advisories on later from Integrations → Advisory & VEX Sources.

@@ -1451,8 +1451,8 @@ export function mergeSetupStepLocalDefaults(
@@ -1460,7 +1460,7 @@ export function mergeSetupStepLocalDefaults( @if (sourceFeedMode() === 'mirror') {

Mirror Configuration

-

Connect to a reachable advisory mirror endpoint. For local/offline setup, prefer Custom Feed Sources unless a mirror URL is explicitly available.

+

StellaOps Mirror is the default recommended path. Leave the default URL in place unless your environment uses a different reachable mirror endpoint.

- Optional. Provides higher rate limits and access to priority feeds. + @if (hasRetainedSecret('sources.mirror.apiKey') && !getConfigValue('sources.mirror.apiKey')) { + Mirror API key retained securely on the server. Leave the field untouched to keep it, or type a new key to replace it. + } @else { + Optional. Provides higher rate limits and access to priority feeds. + }
} @@ -1488,8 +1492,8 @@ export function mergeSetupStepLocalDefaults( @if (sourceFeedMode() === 'custom') {
-

Individual Feed Sources

-

Enable and configure individual advisory data feeds.

+

Manual Advisory & VEX Sources

+

Enable and configure individual advisory and VEX data feeds.

@for (source of sourcesProviders; track source.id) {
@@ -1914,6 +1918,8 @@ export class StepContentComponent { 'https://mirrors.stella-ops.org/feeds/', ]); + private static readonly DEFAULT_STELLA_MIRROR_URL = 'https://mirror.stella-ops.org'; + /** Emit defaults for the current step if no values are set yet. */ private readonly defaultsEffect = effect(() => { const step = this.tryGetStep(); @@ -1948,6 +1954,34 @@ export class StepContentComponent { if (sourceMode && !this.sourceFeedMode()) { this.sourceFeedMode.set(sourceMode); } + if (!sourceMode && !this.sourceFeedMode()) { + this.sourceFeedMode.set('mirror'); + } + + const selectedSources = new Set(); + const enabledList = config['sources.enabled']; + if (enabledList) { + for (const sourceId of enabledList.split(',').map(value => value.trim()).filter(Boolean)) { + selectedSources.add(sourceId); + } + } + for (const [key, value] of Object.entries(config)) { + if (!key.startsWith('sources.') || !key.endsWith('.enabled')) { + continue; + } + + const sourceId = key.slice('sources.'.length, -'.enabled'.length); + if (!sourceId || sourceId === 'mirror') { + continue; + } + + if (value === 'true') { + selectedSources.add(sourceId); + } else if (value === 'false') { + selectedSources.delete(sourceId); + } + } + this.enabledSources.set(selectedSources); const mirrorUrlRaw = config['sources.mirror.url']; const mirrorUrl = typeof mirrorUrlRaw === 'string' @@ -1958,10 +1992,9 @@ export class StepContentComponent { StepContentComponent.LEGACY_MIRROR_ENDPOINT_DEFAULTS.has(mirrorUrl) ) { this.legacyMirrorDefaultsSanitized = true; - this.configChange.emit({ key: 'sources.mirror.url', value: '' }); - this.configChange.emit({ key: 'sources.mirror.apiKey', value: '' }); - this.sourceFeedMode.set('custom'); - this.configChange.emit({ key: 'sources.mode', value: 'custom' }); + this.configChange.emit({ key: 'sources.mirror.url', value: StepContentComponent.DEFAULT_STELLA_MIRROR_URL }); + this.sourceFeedMode.set('mirror'); + this.configChange.emit({ key: 'sources.mode', value: 'mirror' }); } }); @@ -1977,7 +2010,7 @@ export class StepContentComponent { readonly sourceFeedMode = signal<'mirror' | 'custom' | null>(null); // Enabled sources (track by source ID) - readonly enabledSources = signal>(new Set(['nvd', 'ghsa'])); + readonly enabledSources = signal>(new Set()); // Migrations state readonly pendingMigrations = signal([]); diff --git a/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/step-content.defaults.spec.ts b/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/step-content.defaults.spec.ts index 0d37b0041..a3a84343d 100644 --- a/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/step-content.defaults.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/step-content.defaults.spec.ts @@ -9,8 +9,8 @@ describe('StepContentComponent local defaults', () => { 'authority.provider': 'standard', 'users.superuser.username': 'admin', 'users.superuser.email': 'admin@stella-ops.local', - 'users.superuser.password': 'Admin@Stella1', })); + expect(SETUP_STEP_LOCAL_DEFAULTS.admin['users.superuser.password']).toBeUndefined(); }); it('keeps the legacy authority and users defaults for compatibility', () => { @@ -30,8 +30,11 @@ describe('StepContentComponent local defaults', () => { 'authority.provider': 'standard', 'users.superuser.username': 'custom-admin', 'users.superuser.email': 'admin@stella-ops.local', - 'users.superuser.password': 'Admin@Stella1', })); + expect(mergeSetupStepLocalDefaults('admin', { + 'users.superuser.username': 'custom-admin', + 'users.superuser.email': '', + })['users.superuser.password']).toBeUndefined(); }); it('does not inject sensitive defaults when the backend retained a secret for the step', () => { @@ -46,4 +49,34 @@ describe('StepContentComponent local defaults', () => { 'users.superuser.username': 'admin', }, ['users.superuser.password'])['users.superuser.password']).toBeUndefined(); }); + + it('defaults advisory and vex setup to StellaOps Mirror', () => { + expect(SETUP_STEP_LOCAL_DEFAULTS.sources).toEqual(expect.objectContaining({ + 'sources.mode': 'mirror', + 'sources.mirror.url': 'https://mirror.stella-ops.org', + })); + expect(SETUP_STEP_LOCAL_DEFAULTS.sources['sources.mirror.apiKey']).toBeUndefined(); + }); + + it('merges advisory source defaults without overwriting explicit operator input', () => { + expect(mergeSetupStepLocalDefaults('sources', { + 'sources.mode': 'manual', + 'sources.mirror.url': 'https://custom.example', + })).toEqual(expect.objectContaining({ + 'sources.mode': 'manual', + 'sources.mirror.url': 'https://custom.example', + })); + }); + + it('does not inject mirror api key defaults when the backend retained a source secret', () => { + expect(mergeSetupStepLocalDefaults('sources', { + 'sources.mode': 'mirror', + }, ['sources.mirror.apiKey'])).toEqual(expect.objectContaining({ + 'sources.mode': 'mirror', + 'sources.mirror.url': 'https://mirror.stella-ops.org', + })); + expect(mergeSetupStepLocalDefaults('sources', { + 'sources.mode': 'mirror', + }, ['sources.mirror.apiKey'])['sources.mirror.apiKey']).toBeUndefined(); + }); }); diff --git a/src/Web/StellaOps.Web/src/app/features/setup-wizard/models/setup-wizard.models.ts b/src/Web/StellaOps.Web/src/app/features/setup-wizard/models/setup-wizard.models.ts index 76011769f..5b44c1e72 100644 --- a/src/Web/StellaOps.Web/src/app/features/setup-wizard/models/setup-wizard.models.ts +++ b/src/Web/StellaOps.Web/src/app/features/setup-wizard/models/setup-wizard.models.ts @@ -320,11 +320,9 @@ export type SourcesProvider = | 'ubuntu' | 'debian' | 'alpine' - | 'wolfi' - | 'vex-csaf' - | 'vex-openvex' - | 'vex-cyclonedx' - | 'mirror-custom'; + | 'csaf' + | 'csaf-tc' + | 'vex'; /** Authority provider configurations */ export const AUTHORITY_PROVIDERS: ProviderInfo[] = [ @@ -969,65 +967,33 @@ export const SOURCES_PROVIDERS: ProviderInfo[] = [ ], }, { - id: 'wolfi', - name: 'Wolfi Security Advisories', - description: 'Wolfi/Chainguard security advisories', - icon: 'shield', - fields: [ - { id: 'enabled', label: 'Enable Wolfi Feed', type: 'boolean', required: false, defaultValue: true }, - ], - }, - // VEX (Vulnerability Exploitability eXchange) Sources - { - id: 'vex-csaf', - name: 'VEX CSAF Feeds', - description: 'CSAF-format VEX statements from vendors (RedHat, Cisco, etc.)', + id: 'csaf', + name: 'CSAF Aggregator', + description: 'Structured CSAF/VEX provider catalogs and vendor advisories.', icon: 'shield-check', fields: [ - { id: 'enabled', label: 'Enable CSAF VEX', type: 'boolean', required: false, defaultValue: true }, - { id: 'providers', label: 'CSAF Providers', type: 'textarea', required: false, placeholder: 'https://www.redhat.com/.well-known/csaf/provider-metadata.json\nhttps://sec.cloudapps.cisco.com/.well-known/csaf/provider-metadata.json', helpText: 'One provider metadata URL per line' }, - { id: 'trustAnchors', label: 'Trust Anchors (PGP Keys)', type: 'textarea', required: false, helpText: 'PGP key fingerprints for signature verification (one per line)' }, + { id: 'enabled', label: 'Enable CSAF Aggregator', type: 'boolean', required: false, defaultValue: true }, + { id: 'providers', label: 'Provider Catalog URLs', type: 'textarea', required: false, placeholder: 'https://www.redhat.com/.well-known/csaf/provider-metadata.json', helpText: 'Optional override. One provider metadata URL per line.' }, ], }, { - id: 'vex-openvex', - name: 'OpenVEX Feeds', - description: 'OpenVEX-format vulnerability statements', + id: 'csaf-tc', + name: 'Trusted CSAF Catalog', + description: 'Trusted catalog of CSAF providers for vendor VEX retrieval.', icon: 'shield-check', fields: [ - { id: 'enabled', label: 'Enable OpenVEX', type: 'boolean', required: false, defaultValue: true }, - { id: 'feedUrls', label: 'OpenVEX Feed URLs', type: 'textarea', required: false, placeholder: 'https://example.com/vex/feed.json', helpText: 'One feed URL per line' }, + { id: 'enabled', label: 'Enable Trusted CSAF Catalog', type: 'boolean', required: false, defaultValue: true }, + { id: 'catalogUrl', label: 'Catalog URL', type: 'text', required: false, placeholder: 'https://csaf.io/', helpText: 'Optional override for the trusted CSAF catalog endpoint.' }, ], }, { - id: 'vex-cyclonedx', - name: 'CycloneDX VEX', - description: 'CycloneDX BOM with embedded VEX statements', + id: 'vex', + name: 'Generic VEX Feed', + description: 'Generic VEX/OpenVEX document feed for vendor exploitability statements.', icon: 'shield-check', fields: [ - { id: 'enabled', label: 'Enable CycloneDX VEX', type: 'boolean', required: false, defaultValue: true }, - { id: 'watchDirectory', label: 'Watch Directory', type: 'text', required: false, placeholder: '/var/lib/stella/vex/cyclonedx', helpText: 'Directory to watch for VEX BOM files' }, - ], - }, - // Custom Mirror Sources - { - id: 'mirror-custom', - name: 'Custom Advisory Mirror', - description: 'Custom or private advisory feed mirror for air-gapped or enterprise environments', - icon: 'server', - fields: [ - { id: 'enabled', label: 'Enable Custom Mirror', type: 'boolean', required: false, defaultValue: false }, - { id: 'mirrorUrl', label: 'Mirror Base URL', type: 'text', required: true, placeholder: 'https://mirror.internal/advisories', helpText: 'Base URL for the advisory mirror' }, - { id: 'authType', label: 'Authentication', type: 'select', required: false, defaultValue: 'none', options: [ - { value: 'none', label: 'None (Public)' }, - { value: 'basic', label: 'Basic Auth' }, - { value: 'bearer', label: 'Bearer Token' }, - { value: 'mtls', label: 'Mutual TLS' }, - ]}, - { id: 'username', label: 'Username', type: 'text', required: false }, - { id: 'password', label: 'Password/Token', type: 'password', required: false }, - { id: 'caCert', label: 'CA Certificate (PEM)', type: 'textarea', required: false, helpText: 'Custom CA certificate for self-signed mirrors' }, - { id: 'syncInterval', label: 'Sync Interval (hours)', type: 'number', required: false, defaultValue: 6, validation: { min: 1, max: 168 } }, + { id: 'enabled', label: 'Enable Generic VEX Feed', type: 'boolean', required: false, defaultValue: true }, + { id: 'feedUrl', label: 'VEX Feed URL', type: 'text', required: false, placeholder: 'https://example.com/vex/feed.json', helpText: 'Optional override for a vendor or internal VEX document feed.' }, ], }, ]; @@ -1173,7 +1139,7 @@ export const DEFAULT_SETUP_STEPS: SetupStep[] = [ { id: 'sources', name: 'Advisory Data Sources', - description: 'Choose Stella Ops Mirror for pre-aggregated feeds or configure custom advisory sources for CVE/VEX vulnerability data.', + description: 'Enable StellaOps Mirror by default, or switch to manual advisory and VEX source configuration.', category: 'Release Control Plane', order: 60, isRequired: false, @@ -1181,9 +1147,9 @@ export const DEFAULT_SETUP_STEPS: SetupStep[] = [ dependencies: [], validationChecks: ['check.sources.feeds.configured', 'check.sources.feeds.connectivity'], status: 'pending', - configureLaterUiPath: 'Settings → Security Data', + configureLaterUiPath: 'Integrations → Advisory & VEX Sources', configureLaterCliCommand: 'stella config set sources.*', - skipWarning: 'CVE/VEX advisory feeds will require manual updates.', + skipWarning: 'Advisories and VEX stay turned off until you enable a source later from Integrations → Advisory & VEX Sources.', }, // Phase 5: Notifications (Optional) { @@ -1286,11 +1252,12 @@ export const TRUTHFUL_SETUP_STEP_IDS: readonly SetupStepId[] = [ 'migrations', 'admin', 'crypto', + 'sources', ]; export function createTruthfulSetupSteps(): SetupStep[] { const projected = DEFAULT_SETUP_STEPS - .filter(step => step.id === 'database' || step.id === 'cache' || step.id === 'migrations' || step.id === 'crypto') + .filter(step => step.id === 'database' || step.id === 'cache' || step.id === 'migrations' || step.id === 'crypto' || step.id === 'sources') .map(step => ({ ...step })); const welcomeStep: SetupStep = { id: 'welcome', @@ -1347,6 +1314,22 @@ export function createTruthfulSetupSteps(): SetupStep[] { }; } + if (step.id === 'sources') { + return { + ...step, + order: 60, + category: 'Release Control Plane', + description: 'Enable StellaOps Mirror by default, switch to manual advisory/VEX feeds if needed, or skip and turn advisories on later.', + isRequired: false, + isSkippable: true, + dependencies: ['admin'], + validationChecks: ['check.sources.feeds.configured', 'check.sources.feeds.connectivity'], + configureLaterUiPath: 'Integrations → Advisory & VEX Sources', + configureLaterCliCommand: 'stella config set sources.*', + skipWarning: 'Advisories and VEX remain off until you enable a source later from Integrations → Advisory & VEX Sources.', + }; + } + return { ...step, description: 'Verify the configured crypto profile or accept the built-in default profile for the local installation.', diff --git a/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-api.service.spec.ts b/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-api.service.spec.ts index 196e707f8..2ea0838e6 100644 --- a/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-api.service.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-api.service.spec.ts @@ -277,6 +277,7 @@ describe('SetupWizardApiService', () => { expect(parsed).toEqual({ code: 'VALIDATION', message: 'Validation Failed', + status: 503, detail: 'Platform unavailable', retryable: true, suggestedFixes: [ diff --git a/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-api.service.ts b/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-api.service.ts index 62fe91142..5708d7ad6 100644 --- a/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-api.service.ts +++ b/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-api.service.ts @@ -25,6 +25,7 @@ export interface ProblemDetails { export interface SetupApiError { code: string; message: string; + status?: number; detail?: string; retryable: boolean; suggestedFixes?: string[]; @@ -259,6 +260,7 @@ export class SetupWizardApiService { return { code: 'NETWORK_ERROR', message: 'Unable to connect to the server', + status: 0, detail: error.error.message, retryable: true, }; @@ -269,6 +271,7 @@ export class SetupWizardApiService { return { code: this.extractErrorCode(problem), message: problem.title, + status: error.status, detail: problem.detail, retryable: this.isRetryable(error.status), suggestedFixes: this.getSuggestedFixes(problem), @@ -278,6 +281,7 @@ export class SetupWizardApiService { return { code: `HTTP_${error.status}`, message: this.getStatusMessage(error.status), + status: error.status, detail: error.message, retryable: this.isRetryable(error.status), }; diff --git a/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-state.service.spec.ts b/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-state.service.spec.ts index 60ea02084..df45682bc 100644 --- a/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-state.service.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-state.service.spec.ts @@ -96,7 +96,7 @@ describe('SetupWizardStateService', () => { expect(service.configValues()['users.superuser.password']).toBe('Admin@Stella1'); }); - it('falls back to the first pending step when the backend session has no current step', () => { + it('falls back to the first pending real step when the backend session has no current step', () => { const session: SetupSession = { sessionId: 'session-2', scopeKey: 'installation', @@ -116,7 +116,31 @@ describe('SetupWizardStateService', () => { service.initializeSession(session); - expect(service.currentStepId()).toBe('welcome'); + expect(service.currentStepId()).toBe('cache'); + }); + + it('falls back to the last completed real step when the backend session has no current step and no pending steps', () => { + const session: SetupSession = { + sessionId: 'session-2b', + scopeKey: 'installation', + status: 'in_progress', + startedAt: '2026-04-14T00:00:00Z', + definitionVersion: 'v1', + configValues: {}, + steps: [ + { stepId: 'database', status: 'completed' }, + { stepId: 'cache', status: 'completed' }, + { stepId: 'migrations', status: 'completed' }, + { stepId: 'admin', status: 'completed' }, + { stepId: 'crypto', status: 'completed' }, + ], + completedSteps: ['database', 'cache', 'migrations', 'admin', 'crypto'], + skippedSteps: [], + }; + + service.initializeSession(session); + + expect(service.currentStepId()).toBe('crypto'); }); it('navigates backward without returning to welcome', () => { diff --git a/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-state.service.ts b/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-state.service.ts index 5c0ab503c..7c4880fe8 100644 --- a/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-state.service.ts +++ b/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-state.service.ts @@ -268,9 +268,15 @@ export class SetupWizardStateService { if (session.currentStep) { this.currentStepId.set(session.currentStep); } else { - const firstPending = this.orderedSteps().find(s => s.status === 'pending'); + const orderedRealSteps = this.orderedSteps().filter(step => step.id !== 'welcome'); + const firstPending = orderedRealSteps.find(s => s.status === 'pending'); if (firstPending) { this.currentStepId.set(firstPending.id); + } else { + const lastCompleted = [...orderedRealSteps] + .reverse() + .find(step => step.status === 'completed' || step.status === 'skipped' || step.status === 'failed'); + this.currentStepId.set(lastCompleted?.id ?? null); } } } diff --git a/src/Web/StellaOps.Web/src/tests/security-risk/advisory-source-catalog.behavior.spec.ts b/src/Web/StellaOps.Web/src/tests/security-risk/advisory-source-catalog.behavior.spec.ts new file mode 100644 index 000000000..a0041dace --- /dev/null +++ b/src/Web/StellaOps.Web/src/tests/security-risk/advisory-source-catalog.behavior.spec.ts @@ -0,0 +1,265 @@ +import '@angular/compiler'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; +import { provideRouter } from '@angular/router'; +import { of } from 'rxjs'; +import { signal } from '@angular/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { AdvisorySourceCatalogComponent } from '../../app/features/integrations/advisory-vex-sources/advisory-source-catalog.component'; +import { SourceManagementApi } from '../../app/features/integrations/advisory-vex-sources/source-management.api'; +import { MirrorManagementApi } from '../../app/features/integrations/advisory-vex-sources/mirror-management.api'; +import { PlatformContextStore } from '../../app/core/context/platform-context.store'; + +try { + TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); +} catch (error) { + if (!(error instanceof Error) || !error.message.includes('Cannot set base providers')) { + throw error; + } +} + +describe('AdvisorySourceCatalogComponent behavior', () => { + let fixture: ComponentFixture; + let component: AdvisorySourceCatalogComponent; + let sourceApi: jest.Mocked; + let mirrorApi: jest.Mocked; + + const contextStore = { + advisoryCategories: signal([]), + } as Partial; + + beforeEach(async () => { + sourceApi = { + getCatalog: jest.fn(), + getStatus: jest.fn(), + enableSource: jest.fn(), + disableSource: jest.fn(), + checkAll: jest.fn(), + checkSource: jest.fn(), + batchEnable: jest.fn(), + batchDisable: jest.fn(), + getCheckResult: jest.fn(), + syncSource: jest.fn(), + syncAll: jest.fn(), + getSourceMetrics: jest.fn(), + } as unknown as jest.Mocked; + + mirrorApi = { + getConfig: jest.fn(), + getHealthSummary: jest.fn(), + createDomain: jest.fn(), + generateDomain: jest.fn(), + listDomains: jest.fn(), + getDomainConfig: jest.fn(), + getDomainEndpoints: jest.fn(), + testMirror: jest.fn(), + saveConsumerConfig: jest.fn(), + getConsumerConfig: jest.fn(), + discoverDomains: jest.fn(), + discoverSignature: jest.fn(), + updateConfig: jest.fn(), + importBundle: jest.fn(), + getImportStatus: jest.fn(), + } as unknown as jest.Mocked; + + sourceApi.getCatalog.mockReturnValue(of({ + items: [ + { + id: 'stella-mirror', + displayName: 'StellaOps Mirror', + category: 'Mirror', + type: 'mirror', + description: 'Managed pre-aggregated advisory mirror', + baseEndpoint: 'https://mirror.stella-ops.org', + requiresAuth: false, + credentialEnvVar: null, + credentialUrl: null, + documentationUrl: null, + defaultPriority: 100, + regions: [], + tags: [], + enabledByDefault: false, + }, + ], + totalCount: 1, + })); + sourceApi.getStatus.mockReturnValue(of({ + sources: [ + { + sourceId: 'stella-mirror', + enabled: false, + lastCheck: null, + }, + ], + })); + sourceApi.getSourceMetrics.mockReturnValue(of({ + items: [], + totalCount: 0, + dataAsOf: '2026-04-17T12:00:00Z', + })); + sourceApi.enableSource.mockReturnValue(of(void 0)); + sourceApi.disableSource.mockReturnValue(of(void 0)); + sourceApi.checkSource.mockReturnValue(of({ + sourceId: 'stella-mirror', + status: 'healthy', + checkedAt: '2026-04-17T12:00:00Z', + latency: '10ms', + errorMessage: null, + errorCode: null, + httpStatusCode: 200, + possibleReasons: [], + remediationSteps: [], + isHealthy: true, + })); + sourceApi.batchEnable.mockReturnValue(of({ results: [] })); + sourceApi.batchDisable.mockReturnValue(of({ results: [] })); + sourceApi.syncSource.mockReturnValue(of({ + sourceId: 'stella-mirror', + jobKind: 'source:stella-mirror:fetch', + outcome: 'accepted', + runId: 'run-1', + message: null, + })); + + mirrorApi.getConfig.mockReturnValue(of({ + mode: 'Direct', + consumerMirrorUrl: null, + consumerConnected: false, + lastConsumerSync: null, + })); + mirrorApi.getHealthSummary.mockReturnValue(of({ + totalDomains: 0, + freshCount: 0, + staleCount: 0, + neverGeneratedCount: 0, + totalAdvisoryCount: 0, + })); + + await TestBed.configureTestingModule({ + imports: [AdvisorySourceCatalogComponent], + providers: [ + provideRouter([]), + provideNoopAnimations(), + { provide: SourceManagementApi, useValue: sourceApi }, + { provide: MirrorManagementApi, useValue: mirrorApi }, + { provide: PlatformContextStore, useValue: contextStore }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(AdvisorySourceCatalogComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + fixture?.destroy(); + TestBed.resetTestingModule(); + sourceApi.getCatalog.mockReset(); + sourceApi.getStatus.mockReset(); + sourceApi.enableSource.mockReset(); + sourceApi.disableSource.mockReset(); + sourceApi.checkSource.mockReset(); + sourceApi.batchEnable.mockReset(); + sourceApi.batchDisable.mockReset(); + sourceApi.syncSource.mockReset(); + sourceApi.getSourceMetrics.mockReset(); + mirrorApi.getConfig.mockReset(); + mirrorApi.getHealthSummary.mockReset(); + }); + + it('shows an advisories-off banner when no sources are enabled', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const banner = fixture.nativeElement.querySelector('.banner--warning'); + expect(component.enabledCount()).toBe(0); + expect(banner?.textContent).toContain('Advisories and VEX are currently turned off'); + expect(banner?.textContent).toContain('Enable StellaOps Mirror'); + }); + + it('enables StellaOps Mirror from the banner CTA and refreshes status', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const statusCallsBefore = sourceApi.getStatus.mock.calls.length; + const metricsCallsBefore = sourceApi.getSourceMetrics.mock.calls.length; + + component.enableDefaultMirror(); + + expect(sourceApi.enableSource).toHaveBeenCalledWith('stella-mirror'); + expect(sourceApi.getStatus.mock.calls.length).toBe(statusCallsBefore + 1); + expect(sourceApi.getSourceMetrics.mock.calls.length).toBe(metricsCallsBefore + 1); + }); + + it('hides the advisories-off banner when at least one source is enabled', async () => { + sourceApi.getStatus.mockReturnValue(of({ + sources: [ + { + sourceId: 'stella-mirror', + enabled: true, + lastCheck: null, + }, + ], + })); + + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const banner = fixture.nativeElement.querySelector('.banner--warning'); + expect(component.enabledCount()).toBe(1); + expect(banner).toBeFalsy(); + }); + + it('shows a top-level warning when an enabled source has a sync error', async () => { + sourceApi.getStatus.mockReturnValue(of({ + sources: [ + { + sourceId: 'stella-mirror', + enabled: true, + lastCheck: null, + }, + ], + })); + sourceApi.getSourceMetrics.mockReturnValue(of({ + items: [ + { + sourceId: 'stella-mirror', + sourceKey: 'stella-mirror', + sourceName: 'StellaOps Mirror', + totalAdvisories: 0, + signedAdvisories: 0, + unsignedAdvisories: 0, + lastSuccessAt: null, + lastSyncAt: '2026-04-18T10:00:00Z', + syncCount: 0, + errorCount: 1, + freshnessStatus: 'unavailable', + freshnessAgeSeconds: 0, + freshnessSlaSeconds: 0, + lastError: 'RemoteCertificateNameMismatch', + }, + ], + totalCount: 1, + dataAsOf: '2026-04-18T10:00:00Z', + })); + + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const banners = Array.from(fixture.nativeElement.querySelectorAll('.banner--warning')) as HTMLElement[]; + const attentionBanner = banners.find((banner) => + banner.textContent?.includes('enabled advisory source') && + banner.textContent?.includes('RemoteCertificateNameMismatch')); + + expect(component.enabledCount()).toBe(1); + expect(component.enabledSourceFailures().length).toBe(1); + expect(attentionBanner?.textContent).toContain('enabled advisory source'); + expect(attentionBanner?.textContent).toContain('RemoteCertificateNameMismatch'); + expect(attentionBanner?.textContent).toContain('Configure Mirror'); + }); +});