Merge branch 'master' into PRWLR-6707-create-a-way-of-reporting-for-prowler-threatscore

This commit is contained in:
Pedro Martín authored and GitHub committed 2025-07-28 13:20:05 +02:00
commit 5a407ee4c2
112 files changed
+7682 -405

No files matched your search

+2
View File
@@ -12,6 +12,7 @@ All notable changes to the **Prowler UI** are documented in this file.
- Navigation link in Scans view to access Compliance Overview [(#8251)](https://github.com/prowler-cloud/prowler/pull/8251)
- Status column for findings table in the Compliance Detail view [(#8244)](https://github.com/prowler-cloud/prowler/pull/8244)
- Allow to restrict routes access based on user permissions [(#8287)](https://github.com/prowler-cloud/prowler/pull/8287)
- Max character limit validation for Scan label [(#8319)](https://github.com/prowler-cloud/prowler/pull/8319)
### Security
@@ -21,6 +22,7 @@ All notable changes to the **Prowler UI** are documented in this file.
- Upgrade to Next.js 14.2.30 and lock TypeScript to 5.5.4 for ESLint compatibility [(#8189)](https://github.com/prowler-cloud/prowler/pull/8189)
- Improved active step highlighting and updated step titles and descriptions in the Cloud Provider credentials update flow [(#8303)](https://github.com/prowler-cloud/prowler/pull/8303)
- Refactored all existing links across the app to use new custom-link component for consistent styling [(#8341)](https://github.com/prowler-cloud/prowler/pull/8341)
### 🐞 Fixed
+3 -3
View File
@@ -1,10 +1,10 @@
"use client";
import Link from "next/link";
import { useEffect } from "react";
import { RocketIcon } from "@/components/icons";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui";
import { CustomLink } from "@/components/ui/custom/custom-link";
export default function Error({
error,
@@ -27,9 +27,9 @@ export default function Error({
We're sorry for the inconvenience. Please try again or contact
support if the problem persists.
</AlertDescription>
<Link href="/" className="font-bold">
<CustomLink href="/" target="_self" className="font-bold">
Go to the homepage
</Link>
</CustomLink>
</Alert>
);
}
+14 -18
View File
@@ -2,7 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Icon } from "@iconify/react";
import { Button, Checkbox, Divider, Link, Tooltip } from "@nextui-org/react";
import { Button, Checkbox, Divider, Tooltip } from "@nextui-org/react";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
@@ -15,6 +15,7 @@ import { NotificationIcon, ProwlerExtended } from "@/components/icons";
import { ThemeSwitch } from "@/components/ThemeSwitch";
import { useToast } from "@/components/ui";
import { CustomButton, CustomInput } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
import {
Form,
FormControl,
@@ -301,13 +302,12 @@ export const AuthForm = ({
onChange={(e) => field.onChange(e.target.checked)}
>
I agree with the&nbsp;
<Link
<CustomLink
href="https://prowler.com/terms-of-service/"
size="sm"
target="_blank"
>
Terms of Service
</Link>
</CustomLink>
&nbsp;of Prowler
</Checkbox>
</FormControl>
@@ -359,13 +359,9 @@ export const AuthForm = ({
content={
<div className="flex-inline text-small">
Social Login with Google is not enabled.{" "}
<Link
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium text-primary"
>
<CustomLink href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-social-login/#google-oauth-configuration">
Read the docs
</Link>
</CustomLink>
</div>
}
placement="right-start"
@@ -392,13 +388,9 @@ export const AuthForm = ({
content={
<div className="flex-inline text-small">
Social Login with Github is not enabled.{" "}
<Link
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium text-primary"
>
<CustomLink href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-social-login/#github-oauth-configuration">
Read the docs
</Link>
</CustomLink>
</div>
}
placement="right-start"
@@ -451,12 +443,16 @@ export const AuthForm = ({
{type === "sign-in" ? (
<p className="text-center text-small">
Need to create an account?&nbsp;
<Link href="/sign-up">Sign up</Link>
<CustomLink size="base" href="/sign-up" target="_self">
Sign up
</CustomLink>
</p>
) : (
<p className="text-center text-small">
Already have an account?&nbsp;
<Link href="/sign-in">Log in</Link>
<CustomLink size="base" href="/sign-in" target="_self">
Log in
</CustomLink>
</p>
)}
</div>
@@ -1,3 +1,4 @@
import { CustomLink } from "@/components/ui/custom/custom-link";
import { SeverityBadge } from "@/components/ui/table";
import { Requirement } from "@/types/compliance";
@@ -7,7 +8,6 @@ import {
ComplianceDetailContainer,
ComplianceDetailSection,
ComplianceDetailText,
ComplianceLink,
} from "./shared-components";
export const AWSWellArchitectedCustomDetails = ({
@@ -75,11 +75,9 @@ export const AWSWellArchitectedCustomDetails = ({
{requirement.implementation_guidance_url && (
<ComplianceDetailSection title="Implementation Guidance">
<ComplianceLink
href={requirement.implementation_guidance_url as string}
>
<CustomLink href={requirement.implementation_guidance_url as string}>
{requirement.implementation_guidance_url as string}
</ComplianceLink>
</CustomLink>
</ComplianceDetailSection>
)}
</ComplianceDetailContainer>
@@ -1,5 +1,6 @@
import ReactMarkdown from "react-markdown";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { Requirement } from "@/types/compliance";
import {
@@ -8,7 +9,6 @@ import {
ComplianceDetailContainer,
ComplianceDetailSection,
ComplianceDetailText,
ComplianceLink,
} from "./shared-components";
interface CISDetailsProps {
@@ -121,7 +121,7 @@ export const CISCustomDetails = ({ requirement }: CISDetailsProps) => {
{processReferences(requirement.references).map(
(url: string, index: number) => (
<div key={index}>
<ComplianceLink href={url}>{url}</ComplianceLink>
<CustomLink href={url}>{url}</CustomLink>
</div>
),
)}
@@ -1,3 +1,4 @@
import { CustomLink } from "@/components/ui/custom/custom-link";
import { Requirement } from "@/types/compliance";
import {
@@ -7,7 +8,6 @@ import {
ComplianceDetailContainer,
ComplianceDetailSection,
ComplianceDetailText,
ComplianceLink,
} from "./shared-components";
export const MITRECustomDetails = ({
@@ -63,9 +63,9 @@ export const MITRECustomDetails = ({
{requirement.technique_url && (
<ComplianceDetailSection title="MITRE ATT&CK Reference">
<ComplianceLink href={requirement.technique_url as string}>
<CustomLink href={requirement.technique_url as string}>
{requirement.technique_url as string}
</ComplianceLink>
</CustomLink>
</ComplianceDetailSection>
)}
@@ -1,26 +1,5 @@
import Link from "next/link";
import { cn } from "@/lib/utils";
export const ComplianceLink = ({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) => {
return (
<Link
href={href}
target="_blank"
rel="noopener noreferrer"
className="break-all text-sm text-blue-600 decoration-1 transition-colors hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
{children}
</Link>
);
};
export const ComplianceDetailContainer = ({
children,
}: {
@@ -1,10 +1,10 @@
"use client";
import { Snippet } from "@nextui-org/react";
import Link from "next/link";
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import { CustomSection } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { EntityInfoShort, InfoField } from "@/components/ui/entities";
import { DateWithTime } from "@/components/ui/entities/date-with-time";
import { SeverityBadge } from "@/components/ui/table/severity-badge";
@@ -151,15 +151,14 @@ export const FindingDetail = ({
{attributes.check_metadata.remediation.recommendation.text}
</p>
{attributes.check_metadata.remediation.recommendation.url && (
<Link
<CustomLink
href={
attributes.check_metadata.remediation.recommendation.url
}
target="_blank"
className="text-sm text-blue-500 hover:underline"
size="sm"
>
Learn more
</Link>
</CustomLink>
)}
</div>
</InfoField>
@@ -179,13 +178,12 @@ export const FindingDetail = ({
{/* Additional Resources section */}
{attributes.check_metadata.remediation.code.other && (
<InfoField label="Additional Resources">
<Link
<CustomLink
href={attributes.check_metadata.remediation.code.other}
target="_blank"
className="text-sm text-blue-500 hover:underline"
size="sm"
>
View documentation
</Link>
</CustomLink>
</InfoField>
)}
</div>
@@ -1,6 +1,5 @@
"use client";
import Link from "next/link";
import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react";
import { useFormState } from "react-dom";
import { z } from "zod";
@@ -9,6 +8,7 @@ import { createSamlConfig, updateSamlConfig } from "@/actions/integrations";
import { AddIcon } from "@/components/icons";
import { useToast } from "@/components/ui";
import { CustomButton, CustomServerInput } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { SnippetChip } from "@/components/ui/entities";
import { FormButtons } from "@/components/ui/form";
import { apiBaseUrl } from "@/lib";
@@ -239,9 +239,9 @@ export const SamlConfigForm = ({
assign the user&apos;s role. If the role does not exist, one will
be created with minimal permissions. You can assign permissions to
roles on the{" "}
<Link href="/roles">
<span className="underline">Roles</span>
</Link>{" "}
<CustomLink href="/roles" target="_self">
<span>Roles</span>
</CustomLink>{" "}
page.
</p>
</div>
@@ -1,13 +1,13 @@
"use client";
import { Card, CardBody, CardHeader } from "@nextui-org/react";
import { Link } from "@nextui-org/react";
import { CheckIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
import { deleteSamlConfig } from "@/actions/integrations";
import { useToast } from "@/components/ui";
import { CustomAlertModal, CustomButton } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { SamlConfigForm } from "./forms";
@@ -73,14 +73,9 @@ export const SamlIntegrationCard = ({ samlConfig }: { samlConfig?: any }) => {
) : (
<>
Configure SAML Single Sign-On for secure authentication.{" "}
<Link
target="_blank"
href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-sso"
rel="noopener noreferrer"
className="text-xs font-medium text-primary"
>
<CustomLink href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-sso">
Read the docs
</Link>
</CustomLink>
</>
)}
</p>
+6 -4
View File
@@ -1,12 +1,12 @@
"use client";
import { useChat } from "@ai-sdk/react";
import Link from "next/link";
import { useEffect, useRef } from "react";
import { useForm } from "react-hook-form";
import { MemoizedMarkdown } from "@/components/lighthouse/memoized-markdown";
import { CustomButton, CustomTextarea } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { Form } from "@/components/ui/form";
interface SuggestedAction {
@@ -138,12 +138,14 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
? "Please configure your OpenAI API key to use Lighthouse AI."
: "OpenAI API key is invalid. Please update your key to use Lighthouse AI."}
</p>
<Link
<CustomLink
href="/lighthouse/config"
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
target="_self"
size="sm"
>
Configure API Key
</Link>
</CustomLink>
</div>
</div>
)}
@@ -1,7 +1,6 @@
"use client";
import { Textarea } from "@nextui-org/react";
import Link from "next/link";
import { Dispatch, SetStateAction, useEffect, useState } from "react";
import { useFormState } from "react-dom";
@@ -14,6 +13,7 @@ import {
import { DeleteIcon } from "@/components/icons";
import { useToast } from "@/components/ui";
import { CustomButton } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { FormButtons } from "@/components/ui/form";
import { fontMono } from "@/config/fonts";
import { convertToYaml, parseYamlValidation } from "@/lib/yaml";
@@ -178,13 +178,9 @@ export const MutedFindingsConfigForm = ({
</li>
<li>
Learn more about configuring the Mutelist{" "}
<Link
href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/mutelist/"
target="_blank"
className="text-primary-600 hover:underline"
>
<CustomLink href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/mutelist/">
here
</Link>
</CustomLink>
.
</li>
<li>
@@ -3,7 +3,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Icon } from "@iconify/react";
import { Checkbox } from "@nextui-org/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
@@ -18,6 +17,7 @@ import { getTask } from "@/actions/task/tasks";
import { CheckIcon, RocketIcon } from "@/components/icons";
import { useToast } from "@/components/ui";
import { CustomButton } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { Form } from "@/components/ui/form";
import { checkTaskStatus } from "@/lib/helper";
import { ProviderType } from "@/types";
@@ -295,8 +295,9 @@ export const TestConnectionForm = ({
<div className="flex w-full justify-end sm:space-x-6">
{apiErrorMessage ? (
<Link
<CustomLink
href="/providers"
target="_self"
className="mr-3 flex w-fit items-center justify-center space-x-2 rounded-lg border border-solid border-gray-200 px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700"
>
<Icon
@@ -304,7 +305,7 @@ export const TestConnectionForm = ({
className="h-5 w-5 text-gray-600 dark:text-gray-400"
/>
<span>Back to providers</span>
</Link>
</CustomLink>
) : connectionStatus?.error ? (
<CustomButton
onPress={isUpdated ? () => router.back() : onResetCredentials}
@@ -1,8 +1,8 @@
import Link from "next/link";
import { Control } from "react-hook-form";
import { InfoIcon } from "@/components/icons";
import { CustomInput } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { M365Credentials } from "@/types";
export const M365CredentialsForm = ({
@@ -57,14 +57,12 @@ export const M365CredentialsForm = ({
{" "}
User and password authentication is being deprecated due to
Microsoft&apos;s on-going MFA enforcement across all tenants (see{" "}
<Link
<CustomLink
href="https://azure.microsoft.com/en-us/blog/announcing-mandatory-multi-factor-authentication-for-azure-sign-in/"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline"
size="sm"
>
Microsoft docs
</Link>
</CustomLink>
).
</p>
@@ -76,14 +74,12 @@ export const M365CredentialsForm = ({
</div>
<p className="text-sm text-default-500">
Due to that change, you must only{" "}
<Link
<CustomLink
href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/microsoft365/getting-started-m365/#step-3-configure-your-m365-account"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline"
size="sm"
>
use application authentication
</Link>{" "}
</CustomLink>{" "}
to maintain all Prowler M365 scan capabilities.
</p>
<CustomInput
@@ -1,5 +1,6 @@
import Link from "next/link";
"use client";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { getProviderName } from "@/components/ui/entities/get-provider-logo";
import { getProviderLogo } from "@/components/ui/entities/get-provider-logo";
import { getProviderHelpText } from "@/lib";
@@ -24,13 +25,12 @@ export const ProviderTitleDocs = ({
<p className="text-sm text-default-500">
{getProviderHelpText(providerType as string).text}
</p>
<Link
<CustomLink
href={getProviderHelpText(providerType as string).link}
target="_blank"
className="text-sm font-medium text-primary"
size="sm"
>
Read the docs
</Link>
</CustomLink>
</div>
</div>
);
@@ -29,9 +29,13 @@ export const LaunchScanWorkflow = ({
const formSchema = z.object({
...onDemandScanFormSchema().shape,
scanName: z
.string()
.min(3, "Must have at least 3 characters")
.or(z.literal(""))
.union([
z
.string()
.min(3, "Must be at least 3 characters")
.max(32, "Must not exceed 32 characters"),
z.literal(""),
])
.optional(),
});
@@ -101,7 +105,7 @@ export const LaunchScanWorkflow = ({
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -50 }}
transition={{ duration: 0.3 }}
className="min-w-48 self-end"
className="h-[3.4rem] min-w-[15.2rem] self-end"
>
<CustomInput
control={form.control}
+51
View File
@@ -0,0 +1,51 @@
import Link from "next/link";
import React from "react";
import { cn } from "@/lib";
interface CustomLinkProps
extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
href: string;
target?: "_self" | "_blank" | string;
ariaLabel?: string;
className?: string;
children: React.ReactNode;
scroll?: boolean;
size?: string;
}
export const CustomLink = React.forwardRef<HTMLAnchorElement, CustomLinkProps>(
(
{
href,
target = "_blank",
ariaLabel,
className,
children,
scroll = true,
size = "xs",
...props
},
ref,
) => {
return (
<Link
ref={ref}
href={href}
scroll={scroll}
className={cn(
`text-${size} break-all font-medium text-primary decoration-1 hover:underline`,
className,
)}
aria-label={ariaLabel}
target={target}
rel="noopener noreferrer"
{...props}
>
{children}
</Link>
);
},
);
CustomLink.displayName = "CustomLink";
+8 -5
View File
@@ -1,7 +1,6 @@
"use client";
import { LogOut, User } from "lucide-react";
import Link from "next/link";
import { useSession } from "next-auth/react";
import { logOut } from "@/actions/auth";
@@ -10,6 +9,8 @@ import {
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar/avatar";
import { Button } from "@/components/ui/button/button";
import { CustomLink } from "@/components/ui/custom/custom-link";
import {
DropdownMenu,
DropdownMenuContent,
@@ -26,8 +27,6 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip/tooltip";
import { Button } from "../button/button";
export const UserNav = () => {
const { data: session } = useSession();
@@ -80,10 +79,14 @@ export const UserNav = () => {
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem className="hover:cursor-pointer" asChild>
<Link href="/profile" className="flex items-center">
<CustomLink
href="/profile"
className="flex items-center font-normal text-prowler-black dark:text-prowler-white"
target="_self"
>
<User className="text-muted-foreground mr-3 h-4 w-4" />
Account
</Link>
</CustomLink>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
+2 -2
View File
@@ -127,8 +127,8 @@ You operate in an agent loop, iterating through these steps:
- Fetches information related to:
- All findings data across providers. Supports filtering by severity, status, etc.
- Unique metadata values from findings
- Remediation for checks
- Check IDs supported by different provider types
- Available checks for a specific provider (aws, gcp, azure, kubernetes, etc)
- Details of a specific check including details about severity, risk, remediation, compliances that are associated with the check, etc
### roles_agent
+5 -1
View File
@@ -15,7 +15,11 @@ const cspHeader = `
module.exports = {
poweredByHeader: false,
output: "standalone",
// Use standalone only in production deployments, not for CI/testing
...(process.env.NODE_ENV === "production" &&
!process.env.CI && {
output: "standalone",
}),
async headers() {
return [
{
+3 -3
View File
@@ -10075,9 +10075,9 @@
}
},
"node_modules/form-data": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz",
"integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
+13 -3
View File
@@ -8,6 +8,9 @@ export default defineConfig({
workers: process.env.CI ? 1 : undefined,
reporter: [["list"]],
outputDir: "/tmp/playwright-tests",
expect: {
timeout: 20000,
},
use: {
baseURL: "http://localhost:3000",
@@ -24,11 +27,18 @@ export default defineConfig({
],
webServer: {
command: process.env.CI
? "npm run build && npm run start:standalone"
: "npm run dev",
command: process.env.CI ? "npm run start" : "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
env: {
NEXT_PUBLIC_API_BASE_URL:
process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8080/api/v1",
AUTH_SECRET: process.env.AUTH_SECRET || "fallback-ci-secret-for-testing",
AUTH_TRUST_HOST: process.env.AUTH_TRUST_HOST || "true",
NEXTAUTH_URL: process.env.NEXTAUTH_URL || "http://localhost:3000",
E2E_USER: process.env.E2E_USER || "e2e@prowler.com",
E2E_PASSWORD: process.env.E2E_PASSWORD || "Thisisapassword123@",
},
},
});
+212
View File
@@ -0,0 +1,212 @@
import { test, expect } from "@playwright/test";
import {
goToLogin,
goToSignUp,
fillLoginForm,
submitLoginForm,
login,
verifySuccessfulLogin,
verifyLoginError,
verifyLoginFormElements,
verifyDashboardRoute,
toggleSamlMode,
verifySamlModeActive,
goBackFromSaml,
verifyNormalModeActive,
logout,
verifyLogoutSuccess,
waitForPageLoad,
TEST_CREDENTIALS,
ERROR_MESSAGES,
URLS,
verifyLoadingState,
} from "./helpers";
test.describe("Login Flow", () => {
test.beforeEach(async ({ page }) => {
await goToLogin(page);
});
test("should display login form elements", async ({ page }) => {
await verifyLoginFormElements(page);
});
test("should successfully login with valid credentials", async ({ page }) => {
await login(page, TEST_CREDENTIALS.VALID);
await verifySuccessfulLogin(page);
await verifyDashboardRoute(page);
});
test("should show error message with invalid credentials", async ({
page,
}) => {
// Attempt login with invalid credentials
await login(page, TEST_CREDENTIALS.INVALID);
await verifyLoginError(page, ERROR_MESSAGES.INVALID_CREDENTIALS);
});
test("should handle empty form submission", async ({ page }) => {
// Submit empty form
await submitLoginForm(page);
await verifyLoginError(page, ERROR_MESSAGES.INVALID_CREDENTIALS);
// Verify we're still on login page
await expect(page).toHaveURL(URLS.LOGIN);
});
/*
TODO: This test is failing, need UI work before.
test("should validate email format", async ({ page }) => {
// Attempt login with invalid email format
await login(page, TEST_CREDENTIALS.INVALID_EMAIL_FORMAT);
// Verify error message (application shows generic error for invalid email format too)
await verifyLoginError(page, ERROR_MESSAGES.INVALID_CREDENTIALS);
// Verify we're still on login page
await expect(page).toHaveURL(URLS.LOGIN);
});
*/
test("should toggle SAML SSO mode", async ({ page }) => {
// Toggle to SAML mode
await toggleSamlMode(page);
await verifySamlModeActive(page);
// Toggle back to normal mode
await goBackFromSaml(page);
await verifyNormalModeActive(page);
});
test("should show loading state during form submission", async ({ page }) => {
// Fill valid credentials
await fillLoginForm(
page,
TEST_CREDENTIALS.VALID.email,
TEST_CREDENTIALS.VALID.password,
);
// Submit form and verify loading state
await submitLoginForm(page);
// Verify loading state
await verifyLoadingState(page);
});
test("should handle SAML authentication flow", async ({ page }) => {
// Enter email for SAML
const samlEmail = "user@saml-domain.com";
// Toggle to SAML mode
await toggleSamlMode(page);
// Fill email (password should be hidden)
await page.getByLabel("Email").fill(samlEmail);
// Submit should trigger SAML redirect (we can't test the actual SAML flow in E2E)
// but we can verify the form submission
await submitLoginForm(page);
// Note: In a real scenario, this would redirect to IdP
// For testing, we just verify the form was submitted
});
});
test.describe("Session Persistence", () => {
test("should maintain session after browser refresh", async ({ page }) => {
// Login first
await goToLogin(page);
await login(page, TEST_CREDENTIALS.VALID);
await verifySuccessfulLogin(page);
// Refresh the page
await page.reload();
await waitForPageLoad(page);
// Verify session is maintained
await expect(page).toHaveURL(URLS.DASHBOARD);
await verifyDashboardRoute(page);
// Verify user is not redirected back to login
await expect(page).not.toHaveURL(URLS.LOGIN);
});
test("should redirect to login when accessing protected route without session", async ({
page,
}) => {
// Try to access protected route without login
await page.goto(URLS.DASHBOARD);
// Should be redirected to login page
await expect(page).toHaveURL(URLS.LOGIN);
await expect(page.getByText("Sign in", { exact: true })).toBeVisible();
});
test("should logout successfully", async ({ page }) => {
// Login first
await goToLogin(page);
await login(page, TEST_CREDENTIALS.VALID);
await verifySuccessfulLogin(page);
// Logout
await logout(page);
await verifyLogoutSuccess(page);
// Verify cannot access protected route after logout
await page.goto(URLS.DASHBOARD);
await expect(page).toHaveURL(URLS.LOGIN);
});
test("should handle session timeout gracefully", async ({ page }) => {
// Login first
await goToLogin(page);
await login(page, TEST_CREDENTIALS.VALID);
await verifySuccessfulLogin(page);
// Simulate session timeout by clearing cookies
await page.context().clearCookies();
// Try to navigate to a protected route
await page.goto(URLS.PROFILE);
// Should be redirected to login
await expect(page).toHaveURL(URLS.LOGIN);
});
});
test.describe("Navigation", () => {
test("should navigate to sign up page", async ({ page }) => {
await goToLogin(page);
await page.getByRole("link", { name: "Sign up" }).click();
await expect(page).toHaveURL(URLS.SIGNUP);
});
test("should navigate from sign up back to sign in", async ({ page }) => {
await goToSignUp(page);
await page.getByRole("link", { name: "Log in" }).click();
await expect(page).toHaveURL(URLS.LOGIN);
await expect(page.getByText("Sign in", { exact: true })).toBeVisible();
});
test("should handle browser back button correctly", async ({ page }) => {
await goToLogin(page);
await page.getByRole("link", { name: "Sign up" }).click();
await expect(page).toHaveURL(URLS.SIGNUP);
await page.goBack();
await expect(page).toHaveURL(URLS.LOGIN);
await expect(page.getByText("Sign in", { exact: true })).toBeVisible();
});
});
test.describe("Accessibility", () => {
test.beforeEach(async ({ page }) => {
await goToLogin(page);
});
test("should be navigable with keyboard", async ({ page }) => {
// Tab through form elements
await page.keyboard.press("Tab"); // Toggle theme
await page.keyboard.press("Tab"); // Email field
await expect(page.getByLabel("Email")).toBeFocused();
await page.keyboard.press("Tab"); // Password field
await expect(page.getByLabel("Password")).toBeFocused();
await page.keyboard.press("Tab"); // Show password button
await page.keyboard.press("Tab"); // Login button
if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") {
await page.keyboard.press("Tab"); // Forgot password
}
await expect(page.getByRole("button", { name: "Log in" })).toBeFocused();
});
test("should have proper ARIA labels", async ({ page }) => {
await expect(page.getByRole("textbox", { name: "Email" })).toBeVisible();
await expect(page.getByRole("textbox", { name: "Password" })).toBeVisible();
await expect(page.getByRole("button", { name: "Log in" })).toBeVisible();
});
});
-14
View File
@@ -1,14 +0,0 @@
import { test, expect } from "@playwright/test";
test("has title", async ({ page }) => {
await page.goto("https://playwright.dev/");
await expect(page).toHaveTitle(/Playwright/);
});
test("get started link", async ({ page }) => {
await page.goto("https://playwright.dev/");
await page.getByRole("link", { name: "Get started" }).click();
await expect(
page.getByRole("heading", { name: "Installation" }),
).toBeVisible();
});
+136
View File
@@ -0,0 +1,136 @@
import { Page, expect } from "@playwright/test";
export const ERROR_MESSAGES = {
INVALID_CREDENTIALS: "Invalid email or password",
} as const;
export const URLS = {
LOGIN: "/sign-in",
SIGNUP: "/sign-up",
DASHBOARD: "/",
PROFILE: "/profile",
} as const;
export const TEST_CREDENTIALS = {
VALID: {
email: process.env.E2E_USER || "e2e@prowler.com",
password: process.env.E2E_PASSWORD || "Thisisapassword123@",
},
INVALID: {
email: "invalid@example.com",
password: "wrongPassword",
},
INVALID_EMAIL_FORMAT: {
email: "invalid-email",
password: "somepassword",
},
} as const;
export async function goToLogin(page: Page) {
await page.goto("/sign-in");
}
export async function goToSignUp(page: Page) {
await page.goto("/sign-up");
}
export async function fillLoginForm(
page: Page,
email: string,
password: string,
) {
await page.getByLabel("Email").fill(email);
await page.getByLabel("Password").fill(password);
}
export async function submitLoginForm(page: Page) {
await page.getByRole("button", { name: "Log in" }).click();
}
export async function login(
page: Page,
credentials: { email: string; password: string } = TEST_CREDENTIALS.VALID,
) {
await fillLoginForm(page, credentials.email, credentials.password);
await submitLoginForm(page);
}
export async function verifySuccessfulLogin(page: Page) {
await expect(page).toHaveURL("/");
await expect(page.locator("main")).toBeVisible();
await expect(
page
.getByLabel("Breadcrumbs")
.getByRole("heading", { name: "Overview", exact: true }),
).toBeVisible();
}
export async function verifyLoginError(
page: Page,
errorMessage = "Invalid email or password",
) {
await expect(page.getByText(errorMessage)).toBeVisible();
await expect(page).toHaveURL("/sign-in");
}
export async function toggleSamlMode(page: Page) {
await page.getByText("Continue with SAML SSO").click();
}
export async function goBackFromSaml(page: Page) {
await page.getByText("Back").click();
}
export async function verifySamlModeActive(page: Page) {
await expect(page.getByText("Sign in with SAML SSO")).toBeVisible();
await expect(page.getByLabel("Password")).not.toBeVisible();
await expect(page.getByText("Back")).toBeVisible();
}
export async function verifyNormalModeActive(page: Page) {
await expect(page.getByText("Sign in", { exact: true })).toBeVisible();
await expect(page.getByLabel("Password")).toBeVisible();
}
export async function logout(page: Page) {
await page.getByRole("button", { name: "Sign out" }).click();
}
export async function verifyLogoutSuccess(page: Page) {
await expect(page).toHaveURL("/sign-in");
await expect(page.getByText("Sign in", { exact: true })).toBeVisible();
}
export async function verifyLoadingState(page: Page) {
const submitButton = page.getByRole("button", { name: "Log in" });
await expect(submitButton).toHaveAttribute("aria-disabled", "true");
await expect(page.getByText("Loading")).toBeVisible();
}
export async function verifyLoginFormElements(page: Page) {
await expect(page).toHaveTitle(/Prowler/);
await expect(page.locator('svg[width="300"]')).toBeVisible();
// Verify form elements
await expect(page.getByText("Sign in", { exact: true })).toBeVisible();
await expect(page.getByLabel("Email")).toBeVisible();
await expect(page.getByLabel("Password")).toBeVisible();
await expect(page.getByRole("button", { name: "Log in" })).toBeVisible();
// Verify OAuth buttons
await expect(page.getByText("Continue with Google")).toBeVisible();
await expect(page.getByText("Continue with Github")).toBeVisible();
await expect(page.getByText("Continue with SAML SSO")).toBeVisible();
// Verify navigation links
await expect(page.getByText("Need to create an account?")).toBeVisible();
await expect(page.getByRole("link", { name: "Sign up" })).toBeVisible();
}
export async function waitForPageLoad(page: Page) {
await page.waitForLoadState("networkidle");
}
export async function verifyDashboardRoute(page: Page) {
await expect(page).toHaveURL("/");
}
+4 -1
View File
@@ -34,7 +34,10 @@ export const editScanFormSchema = (currentName: string) =>
scanName: z
.string()
.refine((val) => val === "" || val.length >= 3, {
message: "The alias must be empty or have at least 3 characters.",
message: "Must be empty or have at least 3 characters.",
})
.refine((val) => val === "" || val.length <= 32, {
message: "Must not exceed 32 characters.",
})
.refine((val) => val !== currentName, {
message: "The new name must be different from the current one.",
+1 -1
View File
@@ -10,5 +10,5 @@ export const checkSchema = z.object({
});
export const checkDetailsSchema = z.object({
id: z.string(),
checkId: z.string(),
});