mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
feat: record the tenant profile declared at onboarding (#12818)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9f616a5d43
commit
3069486549
@@ -0,0 +1,200 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
fetchMock,
|
||||
getAuthHeadersMock,
|
||||
handleApiErrorMock,
|
||||
handleApiResponseMock,
|
||||
} = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
getAuthHeadersMock: vi.fn(),
|
||||
handleApiErrorMock: vi.fn(),
|
||||
handleApiResponseMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiError: handleApiErrorMock,
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
isOnboardingProfileRecorded,
|
||||
skipOnboardingProfile,
|
||||
submitOnboardingProfile,
|
||||
} from "./profile";
|
||||
|
||||
const ANSWERS = {
|
||||
declared_cloud_accounts: "11-50",
|
||||
declared_role: "security",
|
||||
declared_seniority: "director",
|
||||
} as const;
|
||||
|
||||
describe("onboarding profile actions", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
fetchMock.mockReset();
|
||||
getAuthHeadersMock.mockReset().mockResolvedValue({});
|
||||
handleApiErrorMock.mockReset();
|
||||
handleApiResponseMock.mockReset().mockResolvedValue({ data: { id: "p1" } });
|
||||
});
|
||||
|
||||
it("reports a stored profile and sends the declared buckets", async () => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValue(new Response("{}", { status: 201 }));
|
||||
|
||||
// When
|
||||
const result = await submitOnboardingProfile(ANSWERS);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({ stored: true });
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("https://api.example.com/api/v1/onboarding-profiles");
|
||||
expect(JSON.parse(init.body).data).toEqual({
|
||||
type: "onboarding-profiles",
|
||||
attributes: ANSWERS,
|
||||
});
|
||||
});
|
||||
|
||||
it("records a skip as its own payload", async () => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValue(new Response("{}", { status: 201 }));
|
||||
|
||||
// When
|
||||
const result = await skipOnboardingProfile();
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({ stored: true });
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body).data.attributes).toEqual(
|
||||
{
|
||||
skipped: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("treats a transport failure as not stored", async () => {
|
||||
// Given — the request never reaches the API.
|
||||
fetchMock.mockRejectedValue(new Error("network down"));
|
||||
|
||||
// When
|
||||
const result = await submitOnboardingProfile(ANSWERS);
|
||||
|
||||
// Then
|
||||
expect(result.stored).toBe(false);
|
||||
expect(result.error).toBeTruthy();
|
||||
expect(handleApiErrorMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("treats a rejection without an errors array as not stored", async () => {
|
||||
// Given — e.g. a 403, which `handleApiResponse` returns as a bare error.
|
||||
fetchMock.mockResolvedValue(new Response("{}", { status: 403 }));
|
||||
handleApiResponseMock.mockResolvedValue({
|
||||
error: "Forbidden",
|
||||
status: 403,
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await submitOnboardingProfile(ANSWERS);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({ stored: false, error: "Forbidden" });
|
||||
});
|
||||
|
||||
it("surfaces the API's own message when the payload carries one", async () => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValue(new Response("{}", { status: 400 }));
|
||||
handleApiResponseMock.mockResolvedValue({
|
||||
error: "Bad request",
|
||||
errors: [{ detail: "This field is required." }],
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await submitOnboardingProfile(ANSWERS);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
stored: false,
|
||||
error: "This field is required.",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a thrown server error as not stored", async () => {
|
||||
// Given — `handleApiResponse` throws on 5xx.
|
||||
fetchMock.mockResolvedValue(new Response("{}", { status: 500 }));
|
||||
handleApiResponseMock.mockRejectedValue(new Error("server error"));
|
||||
|
||||
// When
|
||||
const result = await skipOnboardingProfile();
|
||||
|
||||
// Then
|
||||
expect(result.stored).toBe(false);
|
||||
expect(handleApiErrorMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a submission", () => submitOnboardingProfile(ANSWERS)],
|
||||
["a skip", () => skipOnboardingProfile()],
|
||||
])(
|
||||
"reports %s as unstored when the session cannot be read",
|
||||
async (_, run) => {
|
||||
// Given — `auth()` rejects, e.g. a session this deployment cannot decode.
|
||||
getAuthHeadersMock.mockRejectedValue(new Error("session unreadable"));
|
||||
|
||||
// When
|
||||
const result = await run();
|
||||
|
||||
// Then — a result, never a throw: the step stays eligible next login.
|
||||
expect(result).toEqual({ stored: false, error: expect.any(String) });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(handleApiErrorMock).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("reads an unreadable session as an unknown profile state", async () => {
|
||||
// Given — this runs in the root layout, so a throw would abort its render.
|
||||
getAuthHeadersMock.mockRejectedValue(new Error("session unreadable"));
|
||||
|
||||
// When / Then — `undefined` makes the gate fail open.
|
||||
await expect(isOnboardingProfileRecorded()).resolves.toBeUndefined();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses answers outside the declared vocabulary", async () => {
|
||||
// When / Then — validation happens before any request.
|
||||
await expect(
|
||||
submitOnboardingProfile({
|
||||
...ANSWERS,
|
||||
declared_role: "ceo",
|
||||
} as unknown as typeof ANSWERS),
|
||||
).rejects.toThrow();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an empty list", { data: [] }, false],
|
||||
["a stored row", { data: [{ id: "p1" }] }, true],
|
||||
])("reads %s as recorded=%s", async (_, payload, expected) => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify(payload), { status: 200 }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(await isOnboardingProfileRecorded()).toBe(expected);
|
||||
});
|
||||
|
||||
it("returns undefined when the read fails, so the gate fails open", async () => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValue(new Response("{}", { status: 500 }));
|
||||
|
||||
// Then
|
||||
expect(await isOnboardingProfileRecorded()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import {
|
||||
DECLARED_CLOUD_ACCOUNTS,
|
||||
DECLARED_ROLE,
|
||||
DECLARED_SENIORITY,
|
||||
type OnboardingProfileAnswers,
|
||||
} from "@/types/onboarding-profile";
|
||||
|
||||
const ONBOARDING_PROFILES_PATH = "/onboarding-profiles";
|
||||
const RESOURCE_TYPE = "onboarding-profiles";
|
||||
|
||||
// Explicit outcome instead of the raw API payload: the caller records the
|
||||
// step as resolved only when the row was really written, and a transport
|
||||
// failure is as unsuccessful as a rejected payload.
|
||||
export interface OnboardingProfileResult {
|
||||
stored: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const onboardingProfileAnswersSchema = z.object({
|
||||
declared_cloud_accounts: z.enum(
|
||||
Object.values(DECLARED_CLOUD_ACCOUNTS) as [string, ...string[]],
|
||||
),
|
||||
declared_role: z.enum(Object.values(DECLARED_ROLE) as [string, ...string[]]),
|
||||
declared_seniority: z.enum(
|
||||
Object.values(DECLARED_SENIORITY) as [string, ...string[]],
|
||||
),
|
||||
});
|
||||
|
||||
const GENERIC_FAILURE = "The onboarding profile could not be saved.";
|
||||
|
||||
const failureMessage = (payload: unknown): string | undefined => {
|
||||
if (typeof payload !== "object" || payload === null) return undefined;
|
||||
const result = payload as { error?: unknown; errors?: unknown };
|
||||
if (Array.isArray(result.errors)) {
|
||||
const detail = (result.errors[0] as { detail?: unknown })?.detail;
|
||||
if (typeof detail === "string") return detail;
|
||||
}
|
||||
return typeof result.error === "string" ? result.error : undefined;
|
||||
};
|
||||
|
||||
const postOnboardingProfile = async (
|
||||
attributes: Record<string, unknown>,
|
||||
): Promise<OnboardingProfileResult> => {
|
||||
const body = JSON.stringify({
|
||||
data: { type: RESOURCE_TYPE, attributes },
|
||||
});
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
// Inside the boundary: `getAuthHeaders` awaits `auth()`, which rejects on
|
||||
// an undecodable session. The contract above promises a result, not a
|
||||
// throw, so a dead session must read as "not stored" and leave the step
|
||||
// eligible for the next login.
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
response = await fetch(`${apiBaseUrl}${ONBOARDING_PROFILES_PATH}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
} catch (error) {
|
||||
handleApiError(error);
|
||||
return { stored: false, error: GENERIC_FAILURE };
|
||||
}
|
||||
|
||||
// `handleApiResponse` reports to Sentry and throws on server errors; the
|
||||
// status is what decides the outcome, since a rejection can come back
|
||||
// without an `errors` array.
|
||||
try {
|
||||
const payload = await handleApiResponse(response);
|
||||
if (!response.ok) {
|
||||
return {
|
||||
stored: false,
|
||||
error: failureMessage(payload) ?? GENERIC_FAILURE,
|
||||
};
|
||||
}
|
||||
return { stored: true };
|
||||
} catch (error) {
|
||||
handleApiError(error);
|
||||
return { stored: false, error: GENERIC_FAILURE };
|
||||
}
|
||||
};
|
||||
|
||||
// Records the three declared buckets. The API keeps the first answer per
|
||||
// tenant: a repeated submission answers 200 with the stored profile.
|
||||
export const submitOnboardingProfile = async (
|
||||
answers: OnboardingProfileAnswers,
|
||||
): Promise<OnboardingProfileResult> =>
|
||||
postOnboardingProfile(onboardingProfileAnswersSchema.parse(answers));
|
||||
|
||||
// A skip is a fact worth storing: it separates "declined" from "never
|
||||
// shown" in the funnel.
|
||||
export const skipOnboardingProfile =
|
||||
async (): Promise<OnboardingProfileResult> =>
|
||||
postOnboardingProfile({ skipped: true });
|
||||
|
||||
// Whether the tenant already went through the step on any device. `undefined`
|
||||
// means the read failed; the gate fails open and does not force the modal.
|
||||
export const isOnboardingProfileRecorded = async (): Promise<
|
||||
boolean | undefined
|
||||
> => {
|
||||
const url = new URL(`${apiBaseUrl}${ONBOARDING_PROFILES_PATH}`);
|
||||
url.searchParams.set("page[size]", "1");
|
||||
|
||||
try {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const response = await fetch(url.toString(), { headers });
|
||||
if (!response.ok) return undefined;
|
||||
const payload = await response.json();
|
||||
return Array.isArray(payload?.data) ? payload.data.length > 0 : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user