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:
Prowler BotandAlejandro Bailo authored and GitHub committed 2026-08-04 14:42:54 +02:00
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,
+17 -5
View File
@@ -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>
))}
+4
View File
@@ -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;
+119 -4
View File
@@ -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;
+70 -2
View File
@@ -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",
+1 -5
View File
@@ -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,