diff --git a/backend/apps/owasp/graphql/nodes/snapshot.py b/backend/apps/owasp/graphql/nodes/snapshot.py index 53c15fd783..0f9f6371ea 100644 --- a/backend/apps/owasp/graphql/nodes/snapshot.py +++ b/backend/apps/owasp/graphql/nodes/snapshot.py @@ -22,6 +22,7 @@ class SnapshotNode(GenericEntityNode): new_projects = graphene.List(ProjectNode) new_releases = graphene.List(ReleaseNode) new_users = graphene.List(UserNode) + summary = graphene.String() class Meta: model = Snapshot @@ -55,3 +56,7 @@ def resolve_new_releases(self, info): def resolve_new_users(self, info): """Resolve recent new users.""" return self.new_users.order_by("-created_at") + + def resolve_summary(self, info): + """Resolve summary of the snapshot.""" + return self.generate_summary() diff --git a/backend/apps/owasp/models/snapshot.py b/backend/apps/owasp/models/snapshot.py index 6b4edf7acc..a08b0446c8 100644 --- a/backend/apps/owasp/models/snapshot.py +++ b/backend/apps/owasp/models/snapshot.py @@ -51,3 +51,34 @@ def save(self, *args, **kwargs): self.key = now().strftime("%Y-%m") super().save(*args, **kwargs) + + def generate_summary(self, max_examples=2): + """Generate a snapshot summary with counts and examples.""" + summary_parts = [] + + def summarize(queryset, label, example_attr): + count = queryset.count() + if count == 0: + return None + examples = list(queryset.values_list(example_attr, flat=True)[:max_examples]) + example_str = ", ".join(str(e) for e in examples) + return f"{count} {label}{'s' if count != 1 else ''} (e.g., {example_str})" + + entities = [ + (self.new_users, "user", "login"), + (self.new_projects, "project", "name"), + (self.new_chapters, "chapter", "name"), + (self.new_issues, "issue", "title"), + (self.new_releases, "release", "tag_name"), + ] + + for queryset, label, attr in entities: + part = summarize(queryset, label, attr) + if part: + summary_parts.append(part) + + return ( + "Snapshot Summary: " + "; ".join(summary_parts) + if summary_parts + else "No new entities were added." + ) diff --git a/cspell/custom-dict.txt b/cspell/custom-dict.txt index a41738a569..845f7bcb39 100644 --- a/cspell/custom-dict.txt +++ b/cspell/custom-dict.txt @@ -1,4 +1,7 @@ a2eeef +abhayymishraaaa +vithobasatish +Juiz Agsoc Aichi Aissue @@ -20,6 +23,7 @@ csrfguard csrfprotector csrftoken cva +Cyclonedx dismissable DRF dsn diff --git a/frontend/__tests__/unit/data/mockSnapshotData.ts b/frontend/__tests__/unit/data/mockSnapshotData.ts index b420ab141c..d92832aff6 100644 --- a/frontend/__tests__/unit/data/mockSnapshotData.ts +++ b/frontend/__tests__/unit/data/mockSnapshotData.ts @@ -6,6 +6,8 @@ export const mockSnapshotDetailsData = { createdAt: '2025-03-01T22:00:34.361937+00:00', startAt: '2024-12-01T00:00:00+00:00', endAt: '2024-12-31T22:00:30+00:00', + summary: + 'Snapshot Summary: 10 users (e.g., abhayymishraaaa, vithobasatish); 3 projects (e.g., OWASP Top 10 for Business Logic Abuse, OWASP ProdSecMan); 14 chapters (e.g., OWASP Oshawa, OWASP Juiz de Fora); 422 issues (e.g., Duplicate Components, Cyclonedx seems to ignore some configuration options); 71 releases (e.g., 2.0.1, v5.0.1)', status: 'completed', errorMessage: '', newReleases: [ diff --git a/frontend/__tests__/unit/pages/SnapshotDetails.test.tsx b/frontend/__tests__/unit/pages/SnapshotDetails.test.tsx index e5256e1468..4f5692c46b 100644 --- a/frontend/__tests__/unit/pages/SnapshotDetails.test.tsx +++ b/frontend/__tests__/unit/pages/SnapshotDetails.test.tsx @@ -79,6 +79,43 @@ describe('SnapshotDetailsPage', () => { expect(screen.getByText('New Releases')).toBeInTheDocument() }) + test('correctly parses and displays summary data', async () => { + ;(useQuery as jest.Mock).mockReturnValue({ + data: mockSnapshotDetailsData, + error: null, + }) + + render() + + // Wait for the page to render + await waitFor(() => { + expect(screen.getByText('New Snapshot')).toBeInTheDocument() + }) + + // Check that summary section exists + expect(screen.getByText('Snapshot Summary')).toBeInTheDocument() + + // Check for correctly parsed user count + expect(screen.getByText('10 Users')).toBeInTheDocument() + expect(screen.getByText(/abhayymishraaaa/)).toBeInTheDocument() + + // Check for correctly parsed project count + expect(screen.getByText('3 Projects')).toBeInTheDocument() + expect(screen.getByText(/OWASP Top 10 for Business Logic Abuse/)).toBeInTheDocument() + + // Check for correctly parsed chapter count + expect(screen.getByText('14 Chapters')).toBeInTheDocument() + expect(screen.getByText(/OWASP Oshawa/)).toBeInTheDocument() + + // Check for correctly parsed issues count + expect(screen.getByText('422 Issues')).toBeInTheDocument() + expect(screen.getByText(/Duplicate Components/)).toBeInTheDocument() + + // Check for correctly parsed releases count + expect(screen.getByText('71 Releases')).toBeInTheDocument() + expect(screen.getByText(/2\.0\.1/)).toBeInTheDocument() + }) + test('renders error message when GraphQL request fails', async () => { ;(useQuery as jest.Mock).mockReturnValue({ data: null, diff --git a/frontend/src/app/snapshots/[id]/page.tsx b/frontend/src/app/snapshots/[id]/page.tsx index 37fb9f1fbc..ca4cdad16a 100644 --- a/frontend/src/app/snapshots/[id]/page.tsx +++ b/frontend/src/app/snapshots/[id]/page.tsx @@ -1,9 +1,18 @@ 'use client' + import { useQuery } from '@apollo/client' -import { faCalendar } from '@fortawesome/free-solid-svg-icons' +import { + faCalendar, + faUsers, + faFolder, + faBook, + faBug, + faTag, +} from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { useRouter, useParams } from 'next/navigation' import React, { useState, useEffect } from 'react' + import { GET_SNAPSHOT_DETAILS } from 'server/queries/snapshotQueries' import { ChapterTypeGraphQL } from 'types/chapter' import { ProjectTypeGraphql } from 'types/project' @@ -11,17 +20,59 @@ import { SnapshotDetailsProps } from 'types/snapshot' import { level } from 'utils/data' import { formatDate } from 'utils/dateFormatter' import { getFilteredIconsGraphql, handleSocialUrls } from 'utils/utility' + import FontAwesomeIconWrapper from 'wrappers/FontAwesomeIconWrapper' import Card from 'components/Card' import ChapterMapWrapper from 'components/ChapterMapWrapper' import LoadingSpinner from 'components/LoadingSpinner' import { handleAppError, ErrorDisplay } from 'app/global-error' +type ParsedSummary = { + users: { count: number; examples: string[] } + projects: { count: number; examples: string[] } + chapters: { count: number; examples: string[] } + issues: { count: number; examples: string[] } + releases: { count: number; examples: string[] } +} + +const parseSnapshotSummary = (summary: string): ParsedSummary => { + const result: ParsedSummary = { + users: { count: 0, examples: [] }, + projects: { count: 0, examples: [] }, + chapters: { count: 0, examples: [] }, + issues: { count: 0, examples: [] }, + releases: { count: 0, examples: [] }, + } + + if (!summary) return result + + const sections = [ + { key: 'users', pattern: /(\d+) users \(e\.g\.,\s*([^)]+)\)/i }, + { key: 'projects', pattern: /(\d+) projects \(e\.g\.,\s*([^)]+)\)/i }, + { key: 'chapters', pattern: /(\d+) chapters \(e\.g\.,\s*([^)]+)\)/i }, + { key: 'issues', pattern: /(\d+) issues \(e\.g\.,\s*([^)]+)\)/i }, + { key: 'releases', pattern: /(\d+) releases \(e\.g\.,\s*([^)]+)\)/i }, + ] + + sections.forEach((section) => { + const match = summary.match(section.pattern) + if (match && match.length >= 3) { + result[section.key as keyof ParsedSummary] = { + count: parseInt(match[1], 10), + examples: match[2].split(',').map((s) => s.trim()), + } + } + }) + + return result +} + const SnapshotDetailsPage: React.FC = () => { const { id: snapshotKey } = useParams() const [snapshot, setSnapshot] = useState(null) const [isLoading, setIsLoading] = useState(true) const router = useRouter() + const [summaryData, setSummaryData] = useState(null) const { data: graphQLData, error: graphQLRequestError } = useQuery(GET_SNAPSHOT_DETAILS, { variables: { key: snapshotKey }, @@ -30,6 +81,7 @@ const SnapshotDetailsPage: React.FC = () => { useEffect(() => { if (graphQLData) { setSnapshot(graphQLData.snapshot) + setSummaryData(parseSnapshotSummary(graphQLData.snapshot.summary)) setIsLoading(false) } if (graphQLRequestError) { @@ -129,6 +181,38 @@ const SnapshotDetailsPage: React.FC = () => { + {summaryData && ( +
+

+ Snapshot Summary +

+
+ {[ + { label: 'Users', data: summaryData.users, icon: faUsers }, + { label: 'Projects', data: summaryData.projects, icon: faFolder }, + { label: 'Chapters', data: summaryData.chapters, icon: faBook }, + { label: 'Issues', data: summaryData.issues, icon: faBug }, + { label: 'Releases', data: summaryData.releases, icon: faTag }, + ].map(({ label, data, icon }) => ( +
+ +
+

+ {data.count} {label} +

+

+ e.g., {data.examples.join(', ')} +

+
+
+ ))} +
+
+ )} + {snapshot?.newChapters && snapshot?.newChapters.length > 0 && (

diff --git a/frontend/src/server/queries/snapshotQueries.ts b/frontend/src/server/queries/snapshotQueries.ts index a69d6fcc0a..fc2e28d37e 100644 --- a/frontend/src/server/queries/snapshotQueries.ts +++ b/frontend/src/server/queries/snapshotQueries.ts @@ -7,6 +7,7 @@ export const GET_SNAPSHOT_DETAILS = gql` key startAt title + summary newReleases { name publishedAt diff --git a/frontend/src/types/snapshot.ts b/frontend/src/types/snapshot.ts index 8ba77ec100..5d6dc14a2d 100644 --- a/frontend/src/types/snapshot.ts +++ b/frontend/src/types/snapshot.ts @@ -16,6 +16,7 @@ export interface SnapshotDetailsProps { newReleases: ReleaseType[] newProjects: ProjectTypeGraphql[] newChapters: ChapterTypeGraphQL[] + summary: string } export interface Snapshots {