mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-11-02 01:15:31 +00:00
feat(ui): capture Lighthouse context on tenant admin pages (#12329)
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
This commit is contained in:
1 parent
a9a60c9448
commit
6e24a49128
6 files changed
+310
-16
No files matched your search
@@ -67,6 +67,88 @@ describe("LighthouseCurrentContextBadge", () => {
|
||||
expect(tooltip).not.toHaveTextContent("status-summary");
|
||||
});
|
||||
|
||||
it("should say when only the page name is shared", async () => {
|
||||
// Given an unregistered route contributes a bare, filterless page item
|
||||
const user = userEvent.setup();
|
||||
render(<LighthouseCurrentContextBadge context={barePageContext()} />);
|
||||
|
||||
// When
|
||||
await user.hover(screen.getByLabelText("Manage Groups context"));
|
||||
|
||||
// Then
|
||||
const tooltip = await screen.findByRole("tooltip");
|
||||
expect(tooltip).toHaveTextContent("Manage Groups");
|
||||
expect(tooltip).toHaveTextContent("Only the current page name is shared.");
|
||||
});
|
||||
|
||||
it("should treat empty filter entries as a bare page", async () => {
|
||||
// Given a stored envelope whose page filters only carry empty values
|
||||
const user = userEvent.setup();
|
||||
const bareContext = barePageContext();
|
||||
const [pageItem] = bareContext.items;
|
||||
if (pageItem.kind !== "page") throw new Error("expected page item");
|
||||
render(
|
||||
<LighthouseCurrentContextBadge
|
||||
context={{
|
||||
...bareContext,
|
||||
items: [{ ...pageItem, filters: { status: [] } }],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.hover(screen.getByLabelText("Manage Groups context"));
|
||||
|
||||
// Then no filters line renders and the page-only notice does
|
||||
const tooltip = await screen.findByRole("tooltip");
|
||||
expect(tooltip).not.toHaveTextContent("Filters:");
|
||||
expect(tooltip).toHaveTextContent("Only the current page name is shared.");
|
||||
});
|
||||
|
||||
it("should not claim a bare page for a single non-page item", async () => {
|
||||
// Given a historical envelope whose only item is not a page item
|
||||
const user = userEvent.setup();
|
||||
const context: LighthouseContextEnvelope = {
|
||||
schemaVersion: 1,
|
||||
transport: "inline",
|
||||
items: [
|
||||
{
|
||||
kind: "scan",
|
||||
id: "scan-1",
|
||||
source: "selection",
|
||||
scopeKey: "scans:/scans",
|
||||
label: "Selected scan",
|
||||
scanId: "scan-1",
|
||||
},
|
||||
],
|
||||
};
|
||||
render(<LighthouseCurrentContextBadge context={context} />);
|
||||
|
||||
// When
|
||||
await user.hover(screen.getByLabelText("Context context"));
|
||||
|
||||
// Then
|
||||
const tooltip = await screen.findByRole("tooltip");
|
||||
expect(tooltip).not.toHaveTextContent(
|
||||
"Only the current page name is shared.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not claim a bare page when filters or items travel too", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(<LighthouseCurrentContextBadge context={findingsContext()} />);
|
||||
|
||||
// When
|
||||
await user.hover(screen.getByLabelText("Findings context"));
|
||||
|
||||
// Then
|
||||
const tooltip = await screen.findByRole("tooltip");
|
||||
expect(tooltip).not.toHaveTextContent(
|
||||
"Only the current page name is shared.",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["resource", resourceContext(), "Resource: resource-1 (bucket-1)"],
|
||||
["scan", scanContext(), "Scan: scan-1"],
|
||||
@@ -98,6 +180,23 @@ describe("LighthouseContextBadge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function barePageContext(): LighthouseContextEnvelope {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
transport: "inline",
|
||||
items: [
|
||||
{
|
||||
kind: "page",
|
||||
id: "other",
|
||||
source: "automatic",
|
||||
scopeKey: "other:/manage-groups",
|
||||
label: "Manage Groups",
|
||||
path: "/manage-groups",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function overviewContext(): LighthouseContextEnvelope {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
|
||||
@@ -85,15 +85,26 @@ function LighthouseContextTooltip({ context }: LighthouseContextBadgeProps) {
|
||||
const page = context.items.find(
|
||||
(item) => item.kind === LIGHTHOUSE_CONTEXT_KIND.PAGE,
|
||||
);
|
||||
const filters =
|
||||
// Entries with no values (possible in stored envelopes) carry nothing, so
|
||||
// they count neither for the filters line nor against the page-only notice.
|
||||
const filterEntries =
|
||||
page?.kind === LIGHTHOUSE_CONTEXT_KIND.PAGE
|
||||
? Object.entries(page.filters ?? {})
|
||||
.map(([key, values]) => `${key}: ${values.join(", ")}`)
|
||||
.join("; ")
|
||||
: "";
|
||||
? Object.entries(page.filters ?? {}).filter(
|
||||
([, values]) => values.length > 0,
|
||||
)
|
||||
: [];
|
||||
const filters = filterEntries
|
||||
.map(([key, values]) => `${key}: ${values.join(", ")}`)
|
||||
.join("; ");
|
||||
const itemDescriptions = context.items
|
||||
.map(getContextItemDescription)
|
||||
.filter((description) => description !== null);
|
||||
// A single filterless page item means the model only learns which page the
|
||||
// user is on — say so instead of implying richer context travels with it.
|
||||
const sharesOnlyPage =
|
||||
page?.kind === LIGHTHOUSE_CONTEXT_KIND.PAGE &&
|
||||
context.items.length === 1 &&
|
||||
filterEntries.length === 0;
|
||||
|
||||
return (
|
||||
<TooltipContent maxWidth="md">
|
||||
@@ -104,6 +115,7 @@ function LighthouseContextTooltip({ context }: LighthouseContextBadgeProps) {
|
||||
</p>
|
||||
)}
|
||||
{filters && <p>Filters: {filters}</p>}
|
||||
{sharesOnlyPage && <p>Only the current page name is shared.</p>}
|
||||
{itemDescriptions.map(({ id, text }) => (
|
||||
<p key={id}>{text}</p>
|
||||
))}
|
||||
|
||||
@@ -57,5 +57,9 @@ export const LIGHTHOUSE_PAGE_ID = {
|
||||
SERVICES: "services",
|
||||
WORKLOADS: "workloads",
|
||||
MUTELIST: "mutelist",
|
||||
ROLES: "roles",
|
||||
USERS: "users",
|
||||
INVITATIONS: "invitations",
|
||||
INTEGRATIONS: "integrations",
|
||||
OTHER: "other",
|
||||
} as const;
|
||||
@@ -418,8 +418,123 @@ export const LIGHTHOUSE_PAGE_DEFINITION_INPUTS = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: LIGHTHOUSE_PAGE_ID.ROLES,
|
||||
label: "Roles",
|
||||
match: (pathname) =>
|
||||
pathname === "/roles" || pathname.startsWith("/roles/"),
|
||||
allowedSearchParams: [...COMMON_LIST_PARAMS, "filter[permission_state]"],
|
||||
suggestions: [
|
||||
{
|
||||
label: "Review role permissions",
|
||||
prompt:
|
||||
"Review the roles on this page. Which ones grant more than their name implies, and what would you trim first?",
|
||||
},
|
||||
{
|
||||
label: "Find overlapping roles",
|
||||
prompt:
|
||||
"Which of these roles overlap enough to be merged, and does any combination of them add up to an escalation risk?",
|
||||
},
|
||||
{
|
||||
label: "Design least privilege",
|
||||
prompt:
|
||||
"Propose a least-privilege role set for my team: which roles should exist, what should each allow, and how do I migrate from the current ones?",
|
||||
},
|
||||
{
|
||||
label: "Audit unlimited visibility",
|
||||
prompt:
|
||||
"Which roles have unlimited visibility across providers, and who actually needs to keep it?",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: LIGHTHOUSE_PAGE_ID.USERS,
|
||||
label: "Users",
|
||||
match: (pathname) =>
|
||||
pathname === "/users" || pathname.startsWith("/users/"),
|
||||
allowedSearchParams: [...COMMON_LIST_PARAMS],
|
||||
suggestions: [
|
||||
{
|
||||
label: "Audit user access",
|
||||
prompt:
|
||||
"Review the users listed here. Who holds admin-level roles, and does anyone look over-privileged for what they do?",
|
||||
},
|
||||
{
|
||||
label: "Find concentrated ownership",
|
||||
prompt:
|
||||
"Is any critical responsibility — provider management, user administration, billing — concentrated in a single person here?",
|
||||
},
|
||||
{
|
||||
label: "Plan an access review",
|
||||
prompt:
|
||||
"Turn this user list into a quarterly access review: who reviews whom, and what should each reviewer verify?",
|
||||
},
|
||||
{
|
||||
label: "Prepare safe offboarding",
|
||||
prompt:
|
||||
"Draft an offboarding checklist for this tenant: what should be revoked or transferred when one of these users leaves?",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: LIGHTHOUSE_PAGE_ID.INVITATIONS,
|
||||
label: "Invitations",
|
||||
match: (pathname) =>
|
||||
pathname === "/invitations" || pathname.startsWith("/invitations/"),
|
||||
allowedSearchParams: [...COMMON_LIST_PARAMS, "filter[state]"],
|
||||
suggestions: [
|
||||
{
|
||||
label: "Review pending invitations",
|
||||
prompt:
|
||||
"Which pending invitations here should be revoked — expired, unfamiliar domains, or broader roles than the invitee needs?",
|
||||
},
|
||||
{
|
||||
label: "Tighten invitation roles",
|
||||
prompt:
|
||||
"Do any open invitations grant admin or unlimited-visibility roles? Suggest a safer default role for new members.",
|
||||
},
|
||||
{
|
||||
label: "Draft onboarding steps",
|
||||
prompt:
|
||||
"Draft the checklist a new member should follow after accepting one of these invitations.",
|
||||
},
|
||||
{
|
||||
label: "Explain invitation lifecycle",
|
||||
prompt:
|
||||
"Explain how invitations work in Prowler: what happens when one expires, and how do I resend or revoke one safely?",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: LIGHTHOUSE_PAGE_ID.INTEGRATIONS,
|
||||
label: "Integrations",
|
||||
match: (pathname) =>
|
||||
pathname === "/integrations" || pathname.startsWith("/integrations/"),
|
||||
// The grid page takes no params and sub-pages pin their integration type
|
||||
// server-side, so the open integration travels in `path`; only the
|
||||
// sub-page tables' sort ever reaches the URL.
|
||||
allowedSearchParams: ["sort"],
|
||||
suggestions: [
|
||||
{
|
||||
label: "Review integration coverage",
|
||||
prompt:
|
||||
"Which integrations am I missing that would get findings to the right team faster — ticketing, chat, or storage?",
|
||||
},
|
||||
{
|
||||
label: "Check integration health",
|
||||
prompt:
|
||||
"Review the integrations configured here. Are any disconnected, misconfigured, or pointing at destinations nobody watches?",
|
||||
},
|
||||
{
|
||||
label: "Route findings to teams",
|
||||
prompt:
|
||||
"Design how findings should flow from Prowler into my existing tools so each team only sees what they own.",
|
||||
},
|
||||
{
|
||||
label: "Harden integration credentials",
|
||||
prompt:
|
||||
"What credentials do these integrations rely on, and how should I scope and rotate them safely?",
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly LighthousePageDefinitionInput[];
|
||||
|
||||
export const LIGHTHOUSE_KNOWN_ROUTE_LABELS = {
|
||||
integrations: "Integrations",
|
||||
} as const;
|
||||
@@ -23,6 +23,13 @@ describe("resolveLighthousePage", () => {
|
||||
["/services", "services"],
|
||||
["/workloads", "workloads"],
|
||||
["/mutelist", "mutelist"],
|
||||
["/roles", "roles"],
|
||||
["/roles/new", "roles"],
|
||||
["/users", "users"],
|
||||
["/invitations", "invitations"],
|
||||
["/invitations/new", "invitations"],
|
||||
["/integrations", "integrations"],
|
||||
["/integrations/jira", "integrations"],
|
||||
])("should resolve %s as %s", (pathname, expectedPageId) => {
|
||||
// Given / When
|
||||
const page = resolveLighthousePage(pathname);
|
||||
@@ -34,14 +41,33 @@ describe("resolveLighthousePage", () => {
|
||||
|
||||
it("should create a labeled fallback for other application pages", () => {
|
||||
// Given / When
|
||||
const page = resolveLighthousePage("/integrations/");
|
||||
const page = resolveLighthousePage("/profile");
|
||||
|
||||
// Then
|
||||
expect(page.id).toBe("other");
|
||||
expect(page.label).toBe("Integrations");
|
||||
expect(page.label).toBe("Profile");
|
||||
expectValidSuggestions(page.suggestions);
|
||||
});
|
||||
|
||||
it("should not match routes that only share a page's prefix", () => {
|
||||
for (const pathname of [
|
||||
"/roles-preview",
|
||||
"/users-report",
|
||||
"/invitations-archive",
|
||||
"/integrations-beta",
|
||||
]) {
|
||||
expect(resolveLighthousePage(pathname).id).toBe("other");
|
||||
}
|
||||
});
|
||||
|
||||
it("should title-case multi-segment fallback routes", () => {
|
||||
const page = resolveLighthousePage("/manage-groups");
|
||||
|
||||
expect(page.id).toBe("other");
|
||||
expect(page.label).toBe("Manage Groups");
|
||||
expect(page.allowedSearchParams).toEqual([]);
|
||||
});
|
||||
|
||||
it("should resolve encoded and decoded dynamic paths to the same scope", () => {
|
||||
expect(getLighthouseScopeKey("/compliance/CSA%20CCM")).toBe(
|
||||
getLighthouseScopeKey("/compliance/CSA CCM"),
|
||||
@@ -161,6 +187,48 @@ describe("buildLighthousePageContext", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should preserve the filter names emitted by tenant admin pages", () => {
|
||||
const roles = buildLighthousePageContext(
|
||||
"/roles",
|
||||
new URLSearchParams({
|
||||
"filter[search]": "admin",
|
||||
sort: "name",
|
||||
"filter[permission_state]": "unlimited",
|
||||
}),
|
||||
);
|
||||
const users = buildLighthousePageContext(
|
||||
"/users",
|
||||
new URLSearchParams({ "filter[search]": "alice" }),
|
||||
);
|
||||
const invitations = buildLighthousePageContext(
|
||||
"/invitations",
|
||||
new URLSearchParams({ "filter[state]": "expired" }),
|
||||
);
|
||||
|
||||
expect(roles.filters).toEqual({
|
||||
permission_state: ["unlimited"],
|
||||
search: ["admin"],
|
||||
sort: ["name"],
|
||||
});
|
||||
expect(users.filters).toEqual({ search: ["alice"] });
|
||||
expect(invitations.filters).toEqual({ state: ["expired"] });
|
||||
});
|
||||
|
||||
it("should capture only sort on integrations pages", () => {
|
||||
// The integration sub-pages pin filter[integration_type] server-side; the
|
||||
// open integration reaches Lighthouse through the page item's path.
|
||||
const context = buildLighthousePageContext(
|
||||
"/integrations/jira",
|
||||
new URLSearchParams({
|
||||
sort: "-inserted_at",
|
||||
"filter[integration_type]": "jira",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(context.path).toBe("/integrations/jira");
|
||||
expect(context.filters).toEqual({ sort: ["-inserted_at"] });
|
||||
});
|
||||
|
||||
it("should preserve the filter names emitted by list-page controls", () => {
|
||||
const findings = buildLighthousePageContext(
|
||||
"/findings",
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
|
||||
import {
|
||||
LIGHTHOUSE_GLOBAL_SUGGESTIONS,
|
||||
LIGHTHOUSE_KNOWN_ROUTE_LABELS,
|
||||
LIGHTHOUSE_PAGE_DEFINITION_INPUTS,
|
||||
} from "./pages.constants";
|
||||
|
||||
@@ -103,10 +102,7 @@ function buildLighthouseScopeKey(
|
||||
|
||||
function createFallbackDefinition(pathname: string): LighthousePageDefinition {
|
||||
const segment = pathname.split("/").filter(Boolean)[0] ?? "overview";
|
||||
const label =
|
||||
LIGHTHOUSE_KNOWN_ROUTE_LABELS[
|
||||
segment as keyof typeof LIGHTHOUSE_KNOWN_ROUTE_LABELS
|
||||
] ?? toTitleCase(segment);
|
||||
const label = toTitleCase(segment);
|
||||
return createPageDefinition({
|
||||
id: LIGHTHOUSE_PAGE_ID.OTHER,
|
||||
label,
|
||||
|
||||
Reference in new issue
Block a user