Repository navigation
HYPERSHELL-112 Dashboard UI #209
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import type { | ||
| DashboardControlPlane, | ||
| DashboardInvocationContext, | ||
| } from "@openshift-online/hypershell-operational-dashboard-ui"; | ||
| import { mockOperationalDashboardMetrics } from "@openshift-online/hypershell-operational-dashboard-ui/fixtures"; | ||
|
|
||
| export function createMockDashboardControlPlane(): DashboardControlPlane { | ||
| return { | ||
| async getOperationalMetrics(context: DashboardInvocationContext) { | ||
| context.signal?.throwIfAborted(); | ||
|
|
||
| await new Promise((resolve) => setTimeout(resolve, 2000)); // This is just for demos for now | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Propagate abort into the mock delay. The timer does not observe The invocation contract permits 🤖 Prompt for AI Agents |
||
|
|
||
| return { | ||
| ...mockOperationalDashboardMetrics, | ||
| lastSuccessfulRefresh: new Date(), | ||
| }; | ||
| }, | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import { createDashboardOperations } from "@openshift-online/hypershell-operational-dashboard-ui"; | ||
|
|
||
| import { createMockDashboardControlPlane } from "../adapters/mock/dashboard-control-plane"; | ||
|
|
||
| export const dashboardOperations = createDashboardOperations({ | ||
| controlPlane: createMockDashboardControlPlane(), | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| import type { Meta, StoryObj } from "@storybook/react-vite"; | ||
| import { | ||
| createDashboardOperations, | ||
| DashboardUiProvider, | ||
| OperationalDashboardPage, | ||
| type DashboardUiNavigation, | ||
| } from "@openshift-online/hypershell-operational-dashboard-ui"; | ||
| import { mockOperationalDashboardMetrics } from "@openshift-online/hypershell-operational-dashboard-ui/fixtures"; | ||
| import { IntlProvider } from "react-intl"; | ||
| import { MemoryRouter, Route, Routes } from "react-router"; | ||
|
|
||
| import { createMockDashboardControlPlane } from "../../adapters/mock/dashboard-control-plane"; | ||
| import { englishMessages } from "../../i18n/catalog"; | ||
| import { ApplicationShell } from "../shell/application-shell"; | ||
|
|
||
| const stubNavigation: DashboardUiNavigation = { | ||
| collectionHref: "/", | ||
| navigate: () => undefined, | ||
| }; | ||
|
|
||
| const stubDashboard = createDashboardOperations({ | ||
| controlPlane: { | ||
| getOperationalMetrics: (context) => { | ||
| context.signal?.throwIfAborted(); | ||
| return Promise.resolve(mockOperationalDashboardMetrics); | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| const mockDashboard = createDashboardOperations({ | ||
| controlPlane: createMockDashboardControlPlane(), | ||
| }); | ||
|
|
||
| function DashboardPreview({ | ||
| metrics, | ||
| useQuery = false, | ||
| }: Readonly<{ | ||
| metrics?: typeof mockOperationalDashboardMetrics; | ||
| useQuery?: boolean; | ||
| }>) { | ||
| return ( | ||
| <DashboardUiProvider | ||
| dashboard={useQuery ? mockDashboard : stubDashboard} | ||
| navigation={stubNavigation} | ||
| > | ||
| <OperationalDashboardPage metrics={metrics} /> | ||
| </DashboardUiProvider> | ||
| ); | ||
| } | ||
|
|
||
| function ShellDashboardPreview() { | ||
| return ( | ||
| <MemoryRouter initialEntries={["/"]}> | ||
| <Routes> | ||
| <Route element={<ApplicationShell />}> | ||
| <Route | ||
| path="/" | ||
| element={ | ||
| <OperationalDashboardPage | ||
| metrics={mockOperationalDashboardMetrics} | ||
| /> | ||
| } | ||
| /> | ||
| </Route> | ||
| </Routes> | ||
| </MemoryRouter> | ||
| ); | ||
| } | ||
|
|
||
| const pseudoMessages = Object.fromEntries( | ||
| Object.entries(englishMessages).map(([id, message]) => [ | ||
| id, | ||
| `[${message.replaceAll("a", "à").replaceAll("e", "ë")}]`, | ||
| ]), | ||
| ); | ||
|
|
||
| const meta = { | ||
| title: "HyperShell/Operational dashboard", | ||
| component: OperationalDashboardPage, | ||
| parameters: { | ||
| layout: "fullscreen", | ||
| }, | ||
| render: () => <DashboardPreview metrics={mockOperationalDashboardMetrics} />, | ||
| } satisfies Meta<typeof OperationalDashboardPage>; | ||
|
|
||
| export default meta; | ||
| type Story = StoryObj<typeof meta>; | ||
|
|
||
| export const MockedMetrics: Story = {}; | ||
|
|
||
| export const WithRefresh: Story = { | ||
| render: () => <DashboardPreview useQuery />, | ||
| }; | ||
|
|
||
| export const InShell: Story = { | ||
| render: () => <ShellDashboardPreview />, | ||
| }; | ||
|
|
||
| export const PseudoLocalized: Story = { | ||
| decorators: [ | ||
| (StoryComponent) => ( | ||
| <IntlProvider locale="en-XA" messages={pseudoMessages}> | ||
| <StoryComponent /> | ||
| </IntlProvider> | ||
| ), | ||
| ], | ||
| }; | ||
|
|
||
| export const RightToLeft: Story = { | ||
| decorators: [ | ||
| (StoryComponent) => ( | ||
| <div dir="rtl" lang="ar"> | ||
| <IntlProvider locale="ar" messages={englishMessages}> | ||
| <StoryComponent /> | ||
| </IntlProvider> | ||
| </div> | ||
| ), | ||
| ], | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| import { render, screen } from "@testing-library/react"; | ||
| import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; | ||
| import { IntlProvider } from "react-intl"; | ||
| import { beforeEach, describe, expect, it, vi } from "vitest"; | ||
|
|
||
| import { englishMessages } from "../../i18n/catalog"; | ||
|
|
||
| const { getSessionMock } = vi.hoisted(() => ({ | ||
| getSessionMock: vi.fn(), | ||
| })); | ||
|
|
||
| vi.mock("../../composition/session-composition", () => ({ | ||
| sessionGateway: { getSession: getSessionMock }, | ||
| })); | ||
|
|
||
| import { RequireDashboardAdmin } from "./require-dashboard-admin"; | ||
|
|
||
| function renderGuard() { | ||
| const queryClient = new QueryClient({ | ||
| defaultOptions: { queries: { retry: false } }, | ||
| }); | ||
|
|
||
| return render( | ||
| <IntlProvider locale="en" messages={englishMessages}> | ||
| <QueryClientProvider client={queryClient}> | ||
| <RequireDashboardAdmin> | ||
| <div data-testid="dashboard-content" /> | ||
| </RequireDashboardAdmin> | ||
| </QueryClientProvider> | ||
| </IntlProvider>, | ||
| ); | ||
| } | ||
|
|
||
| describe("RequireDashboardAdmin", () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| it("renders children when the session is unauthenticated", async () => { | ||
| getSessionMock.mockResolvedValue({ authenticated: false, roles: [] }); | ||
|
|
||
| renderGuard(); | ||
|
|
||
| expect(await screen.findByTestId("dashboard-content")).toBeTruthy(); | ||
| }); | ||
|
|
||
| it("renders children for hypershell-admins", async () => { | ||
| getSessionMock.mockResolvedValue({ | ||
| authenticated: true, | ||
| roles: ["hypershell-admins"], | ||
| }); | ||
|
|
||
| renderGuard(); | ||
|
|
||
| expect(await screen.findByTestId("dashboard-content")).toBeTruthy(); | ||
| }); | ||
|
|
||
| it("renders children for platform:admin", async () => { | ||
| getSessionMock.mockResolvedValue({ | ||
| authenticated: true, | ||
| roles: ["platform:admin"], | ||
| }); | ||
|
|
||
| renderGuard(); | ||
|
|
||
| expect(await screen.findByTestId("dashboard-content")).toBeTruthy(); | ||
| }); | ||
|
|
||
| it("shows access denied for authenticated non-admin users", async () => { | ||
| getSessionMock.mockResolvedValue({ | ||
| authenticated: true, | ||
| roles: ["hypershell-users"], | ||
| }); | ||
|
|
||
| renderGuard(); | ||
|
|
||
| expect( | ||
| await screen.findByRole("heading", { name: "Access denied" }), | ||
| ).toBeTruthy(); | ||
| expect(screen.queryByTestId("dashboard-content")).toBeNull(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: openshift-online/hypershell
Length of output: 22912
🌐 Web query:
actions/checkout v7.0.1 action.yml persist-credentials default GitHub💡 Result:
In the actions/checkout version 7.0.1 action.yml file, the default value for the persist-credentials input is true [1][2]. While there has been community discussion and a pull request (PR
#1687) proposing to change this default to false to reduce security risks related to the exposure of the GITHUB_TOKEN secret [3], the default behavior in v7.0.1 remains set to true [1][2]. When set to true, the action configures the token or SSH key with the local git config, allowing subsequent scripts to run authenticated git commands [4][5]. Users who wish to opt out of this behavior must explicitly set persist-credentials to false in their workflow configuration [4][5].Citations:
Disable Git credential persistence for this checkout.
actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1defaultspersist-credentialstotrue. The followingpnpm installand check steps can execute pull-request-controlled code, which can read and exfiltrate the read-scopedGITHUB_TOKENfrom local Git configuration (CWE-522). Setpersist-credentials: false;permissions: contents: readdoes not prevent token exposure.🧰 Tools
🪛 zizmor (1.29.0)
[warning] 199-200: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false
(artipacked)
🤖 Prompt for AI Agents
Sources: Path instructions, Linters/SAST tools