Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🐞 Fixed

- Compliance page now loads the most recent scan when opened from the sidebar instead of showing the "no compliance data available" alert [(#11374)](https://github.com/prowler-cloud/prowler/pull/11374)
- Invitation links now show specific expired, no-longer-valid, and invalid-token messages based on API error responses [(#11376)](https://github.com/prowler-cloud/prowler/pull/11376)

---

Expand Down
66 changes: 66 additions & 0 deletions ui/actions/auth/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const { fetchMock } = vi.hoisted(() => ({
fetchMock: vi.fn(),
}));

vi.mock("next-auth", () => ({
AuthError: class AuthError extends Error {},
}));

vi.mock("@/auth.config", () => ({
signIn: vi.fn(),
signOut: vi.fn(),
}));

vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
}));

vi.mock("@/lib/sentry-breadcrumbs", () => ({
addAuthEvent: vi.fn(),
}));

import { createNewUser } from "./auth";

describe("auth actions", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
});

it("should preserve HTTP status when user creation fails", async () => {
// Given
const apiResponse = {
errors: [
{
status: "400",
code: "invalid",
detail: "Invalid invitation code.",
source: { pointer: "/data/attributes/invitation_token" },
},
],
};
fetchMock.mockResolvedValue(
new Response(JSON.stringify(apiResponse), {
status: 400,
headers: { "Content-Type": "application/json" },
}),
);

// When
const result = await createNewUser({
name: "Jane Doe",
email: "jane@example.com",
password: "TestPassword123!",
confirmPassword: "TestPassword123!",
company: "Prowler",
invitationToken: "invitation-token",
termsAndConditions: undefined,
isSamlMode: false,
});

// Then
expect(result).toEqual({ ...apiResponse, status: 400 });
});
});
2 changes: 1 addition & 1 deletion ui/actions/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export const createNewUser = async (formData: SignUpFormData) => {

const parsedResponse = await response.json();
if (!response.ok) {
return parsedResponse;
return { ...parsedResponse, status: response.status };
}

return parsedResponse;
Expand Down
235 changes: 235 additions & 0 deletions ui/app/(auth)/invitation/_lib/invitation-errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import { describe, expect, it } from "vitest";

import {
getInvitationErrorDisplay,
INVITATION_ERROR_FLOW,
INVITATION_ERROR_MESSAGES,
isInvitationTokenError,
} from "./invitation-errors";

describe("getInvitationErrorDisplay", () => {
describe("when mapping invitation accept errors", () => {
it("should show expired message for token_expired responses", () => {
// Given
const response = {
status: 410,
errors: [
{
status: "410",
code: "token_expired",
detail: "The invitation token has expired and is no longer valid.",
},
],
};

// When
const result = getInvitationErrorDisplay(
response,
INVITATION_ERROR_FLOW.ACCEPT,
);

// Then
expect(result.message).toBe(INVITATION_ERROR_MESSAGES.EXPIRED);
expect(result.canRetry).toBe(false);
});

it("should show no-longer-valid message for already accepted or revoked invitations", () => {
// Given
const response = {
status: 400,
errors: [
{
status: "400",
code: "invalid",
detail: "This invitation is no longer valid.",
},
],
};

// When
const result = getInvitationErrorDisplay(
response,
INVITATION_ERROR_FLOW.ACCEPT,
);

// Then
expect(result.message).toBe(INVITATION_ERROR_MESSAGES.NO_LONGER_VALID);
expect(result.canRetry).toBe(false);
});

it("should show not-valid message for missing invitation tokens", () => {
// Given
const response = {
status: 404,
errors: [
{
status: "404",
code: "not_found",
detail: "Invitation is not valid.",
},
],
};

// When
const result = getInvitationErrorDisplay(
response,
INVITATION_ERROR_FLOW.ACCEPT,
);

// Then
expect(result.message).toBe(INVITATION_ERROR_MESSAGES.NOT_VALID);
expect(result.canRetry).toBe(false);
});

it("should not allow retry for client-side malformed tokens", () => {
// Given
const response = {
error: "Invalid invitation token",
};

// When
const result = getInvitationErrorDisplay(
response,
INVITATION_ERROR_FLOW.ACCEPT,
);

// Then
expect(result.message).toBe(INVITATION_ERROR_MESSAGES.INVALID_FALLBACK);
expect(result.canRetry).toBe(false);
});
});

describe("when mapping invitation signup errors", () => {
it("should not identify generic data errors as invitation token errors", () => {
// Given
const error = {
status: "400",
code: "invalid",
detail: "Invalid request data.",
source: { pointer: "/data" },
};

// When
const result = isInvitationTokenError(error);

// Then
expect(result).toBe(false);
});

it("should identify invitation token field errors", () => {
// Given
const error = {
status: "400",
code: "invalid",
detail: "Invalid invitation code.",
source: { pointer: "/data/attributes/invitation_token" },
};

// When
const result = isInvitationTokenError(error);

// Then
expect(result).toBe(true);
});

it("should use generic invalid fallback for non-invitation signup errors", () => {
// Given
const response = {
status: 400,
errors: [
{
status: "400",
code: "invalid",
detail: "Invalid email address.",
source: { pointer: "/data/attributes/email" },
},
],
};

// When
const result = getInvitationErrorDisplay(
response,
INVITATION_ERROR_FLOW.SIGNUP,
);

// Then
expect(result.message).toBe(INVITATION_ERROR_MESSAGES.INVALID_FALLBACK);
expect(result.canRetry).toBe(false);
});

it("should show not-valid message for signup invalid invitation tokens", () => {
// Given
const response = {
status: 400,
errors: [
{
status: "400",
code: "invalid",
detail: "Invalid invitation code.",
source: { pointer: "/data/attributes/invitation_token" },
},
],
};

// When
const result = getInvitationErrorDisplay(
response,
INVITATION_ERROR_FLOW.SIGNUP,
);

// Then
expect(result.message).toBe(INVITATION_ERROR_MESSAGES.NOT_VALID);
expect(result.canRetry).toBe(false);
});
});

describe("when the response is unexpected", () => {
it("should use generic invalid fallback for unmapped invalid responses", () => {
// Given
const response = {
status: 400,
errors: [
{
status: "400",
code: "invalid",
detail: "Unexpected invalid invitation response.",
},
],
};

// When
const result = getInvitationErrorDisplay(
response,
INVITATION_ERROR_FLOW.ACCEPT,
);

// Then
expect(result.message).toBe(INVITATION_ERROR_MESSAGES.INVALID_FALLBACK);
expect(result.canRetry).toBe(false);
});

it("should allow retry for unknown responses", () => {
// Given
const response = {
status: 500,
errors: [
{
status: "500",
code: "server_error",
detail: "Something exploded.",
},
],
};

// When
const result = getInvitationErrorDisplay(
response,
INVITATION_ERROR_FLOW.ACCEPT,
);

// Then
expect(result.message).toBe(INVITATION_ERROR_MESSAGES.UNEXPECTED);
expect(result.canRetry).toBe(true);
});
});
});
Loading
Loading