mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
fix(ui): show usage-limit banner across authenticated pages
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({ getTraceData: () => ({}) }));
|
||||
vi.mock("@/actions/providers", () => ({
|
||||
getProviders: vi.fn().mockResolvedValue({ data: [] }),
|
||||
}));
|
||||
vi.mock("@/actions/scans/scans", () => ({
|
||||
getScansByState: vi.fn().mockResolvedValue({ data: [] }),
|
||||
}));
|
||||
vi.mock("@/components/providers/usage-limit-banner.server", () => ({
|
||||
UsageLimitBannerSSR: () => <div role="alert">Usage limit exceeded</div>,
|
||||
}));
|
||||
vi.mock("@/components/layout/main-layout/main-layout", () => ({
|
||||
default: ({
|
||||
usageLimitBanner,
|
||||
children,
|
||||
}: {
|
||||
usageLimitBanner?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<main>
|
||||
{usageLimitBanner}
|
||||
{children}
|
||||
</main>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/components/onboarding", () => ({
|
||||
OnboardingCheckpointWatcher: () => null,
|
||||
OnboardingGate: () => null,
|
||||
OnboardingSequenceBanner: () => null,
|
||||
}));
|
||||
vi.mock("@/components/runtime-config/runtime-public-config", () => ({
|
||||
RuntimePublicConfig: () => null,
|
||||
}));
|
||||
vi.mock("@/components/shadcn/navigation-progress", () => ({
|
||||
NavigationProgress: () => null,
|
||||
}));
|
||||
vi.mock("@/components/shadcn/toast", () => ({ Toaster: () => null }));
|
||||
vi.mock("@/components/shared/task-polling-watcher", () => ({
|
||||
TaskPollingWatcher: () => null,
|
||||
}));
|
||||
vi.mock("@/components/side-panel", () => ({ GlobalSidePanel: () => null }));
|
||||
vi.mock("@/components/survey/feedback-survey", () => ({
|
||||
FeedbackSurvey: () => null,
|
||||
}));
|
||||
vi.mock("@/config/fonts", () => ({
|
||||
fontMono: { variable: "font-mono" },
|
||||
fontSans: { variable: "font-sans" },
|
||||
}));
|
||||
vi.mock("@/config/site", () => ({
|
||||
siteConfig: { name: "Prowler", description: "Prowler" },
|
||||
}));
|
||||
vi.mock("@/lib/shared/env", () => ({ isCloud: () => false }));
|
||||
vi.mock("@/lib/utils", () => ({
|
||||
cn: (...classes: string[]) => classes.join(" "),
|
||||
}));
|
||||
vi.mock("@/store/ui/store-initializer", () => ({
|
||||
StoreInitializer: () => null,
|
||||
}));
|
||||
vi.mock("../providers", () => ({
|
||||
Providers: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
import RootLayout from "./layout";
|
||||
|
||||
describe("authenticated layout", () => {
|
||||
it("composes the usage-limit banner into the main application shell", async () => {
|
||||
// Given / When
|
||||
render(await RootLayout({ children: <div>Page content</div> }));
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Usage limit exceeded");
|
||||
expect(screen.getByText("Page content")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
OnboardingGate,
|
||||
OnboardingSequenceBanner,
|
||||
} from "@/components/onboarding";
|
||||
import { UsageLimitBannerSSR } from "@/components/providers/usage-limit-banner.server";
|
||||
import { RuntimePublicConfig } from "@/components/runtime-config/runtime-public-config";
|
||||
import { NavigationProgress } from "@/components/shadcn/navigation-progress";
|
||||
import { Toaster } from "@/components/shadcn/toast";
|
||||
@@ -108,7 +109,15 @@ export default async function RootLayout({
|
||||
<OnboardingSequenceBanner hasCompletedScan={hasCompletedScan} />
|
||||
</>
|
||||
)}
|
||||
<MainLayout>{children}</MainLayout>
|
||||
<MainLayout
|
||||
usageLimitBanner={
|
||||
<Suspense fallback={null}>
|
||||
<UsageLimitBannerSSR allowHide className="m-4 mr-6" />
|
||||
</Suspense>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</MainLayout>
|
||||
{cloudEnabled && <FeedbackSurvey />}
|
||||
{/* Always mounted: it hosts the detail (finding/resource) views in
|
||||
every deployment; the AI tab inside is cloud-gated on its own. */}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Prowler Cloud usage-limit banner across authenticated application pages
|
||||
@@ -1,8 +1,14 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import MainLayout from "./main-layout";
|
||||
|
||||
const navigationMocks = vi.hoisted(() => ({ pathname: "/findings" }));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => navigationMocks.pathname,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/layout/app-sidebar", () => ({
|
||||
AppSidebar: () => <aside data-testid="sidebar" />,
|
||||
}));
|
||||
@@ -16,6 +22,43 @@ vi.mock("@/components/findings/jira-dispatch-modal-host", () => ({
|
||||
}));
|
||||
|
||||
describe("MainLayout", () => {
|
||||
beforeEach(() => {
|
||||
navigationMocks.pathname = "/findings";
|
||||
});
|
||||
|
||||
it("renders the usage-limit banner before page content", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<MainLayout usageLimitBanner={<div role="alert">Usage limit</div>}>
|
||||
<div>Page content</div>
|
||||
</MainLayout>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const banner = screen.getByRole("alert");
|
||||
const content = screen.getByText("Page content");
|
||||
expect(
|
||||
banner.compareDocumentPosition(content) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("omits the usage-limit banner from billing routes", () => {
|
||||
// Given
|
||||
navigationMocks.pathname = "/billing/invoices";
|
||||
|
||||
// When
|
||||
render(
|
||||
<MainLayout usageLimitBanner={<div role="alert">Usage limit</div>}>
|
||||
<div>Billing content</div>
|
||||
</MainLayout>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Billing content")).toBeVisible();
|
||||
});
|
||||
|
||||
it("mounts the shared Cloud upgrade modal with page content", () => {
|
||||
render(
|
||||
<MainLayout>
|
||||
|
||||
@@ -16,8 +16,18 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSidePanelStore } from "@/store/side-panel";
|
||||
|
||||
export default function MainLayout({ children }: { children: ReactNode }) {
|
||||
interface MainLayoutProps {
|
||||
usageLimitBanner?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function MainLayout({
|
||||
usageLimitBanner,
|
||||
children,
|
||||
}: MainLayoutProps) {
|
||||
const pathname = usePathname();
|
||||
const isBillingRoute =
|
||||
pathname === "/billing" || pathname.startsWith("/billing/");
|
||||
// Push (not overlay): the open side panel shrinks the page by exactly its
|
||||
// (user-resizable) width so everything stays reachable. Below `sm` the
|
||||
// panel overlays full-width instead, where pushing would leave no page. The
|
||||
@@ -57,6 +67,7 @@ export default function MainLayout({ children }: { children: ReactNode }) {
|
||||
)}
|
||||
style={{ marginRight: pushWidth }}
|
||||
>
|
||||
{!isBillingRoute && usageLimitBanner}
|
||||
<Suspense fallback={null}>{children}</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -9,3 +9,5 @@ export * from "./providers-accounts-view";
|
||||
export * from "./providers-filters";
|
||||
export * from "./radio-card";
|
||||
export * from "./radio-group-provider";
|
||||
export * from "./usage-limit-banner";
|
||||
export * from "./usage-limit-banner.server";
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { shouldDisplayUsageLimitBanner } from "./usage-limit-banner.resolver";
|
||||
|
||||
describe("shouldDisplayUsageLimitBanner", () => {
|
||||
it("fails closed for deployments without a private billing resolver", async () => {
|
||||
await expect(shouldDisplayUsageLimitBanner()).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export const shouldDisplayUsageLimitBanner = async (): Promise<boolean> =>
|
||||
false;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { shouldDisplayUsageLimitBannerMock, usageLimitBannerSpy } = vi.hoisted(
|
||||
() => ({
|
||||
shouldDisplayUsageLimitBannerMock: vi.fn(),
|
||||
usageLimitBannerSpy: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("./usage-limit-banner.resolver", () => ({
|
||||
shouldDisplayUsageLimitBanner: shouldDisplayUsageLimitBannerMock,
|
||||
}));
|
||||
|
||||
vi.mock("./usage-limit-banner", () => ({
|
||||
UsageLimitBanner: (props: unknown) => {
|
||||
usageLimitBannerSpy(props);
|
||||
return <div role="alert">Usage limit exceeded</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
import { UsageLimitBannerSSR } from "./usage-limit-banner.server";
|
||||
|
||||
describe("UsageLimitBannerSSR", () => {
|
||||
beforeEach(() => {
|
||||
shouldDisplayUsageLimitBannerMock.mockReset();
|
||||
usageLimitBannerSpy.mockReset();
|
||||
});
|
||||
|
||||
it("renders the existing banner when the neutral resolver returns true", async () => {
|
||||
// Given
|
||||
shouldDisplayUsageLimitBannerMock.mockResolvedValue(true);
|
||||
|
||||
// When
|
||||
render(
|
||||
await UsageLimitBannerSSR({
|
||||
allowHide: true,
|
||||
showBillingButton: false,
|
||||
className: "m-4",
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("alert")).toBeVisible();
|
||||
expect(screen.getByRole("alert").parentElement).toHaveClass("m-4");
|
||||
expect(usageLimitBannerSpy).toHaveBeenCalledWith({
|
||||
allowHide: true,
|
||||
showBillingButton: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders nothing when the neutral resolver returns false", async () => {
|
||||
// Given
|
||||
shouldDisplayUsageLimitBannerMock.mockResolvedValue(false);
|
||||
|
||||
// When
|
||||
const { container } = render(await UsageLimitBannerSSR({}));
|
||||
|
||||
// Then
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(usageLimitBannerSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { UsageLimitBanner } from "./usage-limit-banner";
|
||||
import { shouldDisplayUsageLimitBanner } from "./usage-limit-banner.resolver";
|
||||
|
||||
interface UsageLimitBannerSSRProps {
|
||||
allowHide?: boolean;
|
||||
showBillingButton?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const UsageLimitBannerSSR = async ({
|
||||
allowHide = false,
|
||||
showBillingButton = true,
|
||||
className,
|
||||
}: UsageLimitBannerSSRProps) => {
|
||||
if (!(await shouldDisplayUsageLimitBanner())) return null;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<UsageLimitBanner
|
||||
allowHide={allowHide}
|
||||
showBillingButton={showBillingButton}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { UsageLimitBanner } from "./usage-limit-banner";
|
||||
|
||||
describe("UsageLimitBanner", () => {
|
||||
beforeEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
it("preserves the existing Cloud usage-limit presentation", async () => {
|
||||
// Given / When
|
||||
render(<UsageLimitBanner />);
|
||||
|
||||
// Then
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert).toHaveClass(
|
||||
"animate-fade-in",
|
||||
"border-orange-500",
|
||||
"bg-orange-50",
|
||||
);
|
||||
expect(alert.querySelector("svg")).toHaveClass("lucide-triangle-alert");
|
||||
expect(screen.getByText("Usage limit exceeded")).toBeVisible();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"You have exceeded the usage limit of one provider. You can add more providers and run unlimited scans by adding a subscription.",
|
||||
),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Manage Billing" }),
|
||||
).toHaveAttribute("href", "/billing");
|
||||
});
|
||||
|
||||
it("preserves the existing session dismissal behavior", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(<UsageLimitBanner allowHide />);
|
||||
|
||||
// When
|
||||
await user.click(await screen.findByRole("button", { name: "Close" }));
|
||||
|
||||
// Then
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
expect(window.sessionStorage.getItem("usage-limit-banner-dismissed")).toBe(
|
||||
"true",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
Button,
|
||||
} from "@/components/shadcn";
|
||||
|
||||
const STORAGE_KEY = "usage-limit-banner-dismissed";
|
||||
|
||||
interface UsageLimitBannerProps {
|
||||
allowHide?: boolean;
|
||||
showBillingButton?: boolean;
|
||||
}
|
||||
|
||||
export const UsageLimitBanner = ({
|
||||
allowHide = false,
|
||||
showBillingButton = true,
|
||||
}: UsageLimitBannerProps) => {
|
||||
const [isVisible, setIsVisible] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const isDismissed = sessionStorage.getItem(STORAGE_KEY) === "true";
|
||||
setIsVisible(!isDismissed);
|
||||
}, []);
|
||||
|
||||
const handleClose = () => {
|
||||
sessionStorage.setItem(STORAGE_KEY, "true");
|
||||
setIsVisible(false);
|
||||
};
|
||||
|
||||
// Don't render until we've checked sessionStorage (prevents hydration mismatch)
|
||||
if (isVisible === null || !isVisible) return null;
|
||||
|
||||
return (
|
||||
<Alert
|
||||
variant="warning"
|
||||
onClose={allowHide ? handleClose : undefined}
|
||||
className="animate-fade-in"
|
||||
>
|
||||
<AlertTriangle />
|
||||
<AlertTitle>Usage limit exceeded</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<span>
|
||||
You have exceeded the usage limit of one provider. You can add more
|
||||
providers and run unlimited scans by adding a subscription.
|
||||
</span>
|
||||
{showBillingButton && (
|
||||
<Button
|
||||
asChild
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-fit shrink-0"
|
||||
>
|
||||
<Link href="/billing">Manage Billing</Link>
|
||||
</Button>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user