25 Commits

Author SHA1 Message Date
3dc49925d0 Re-enable GitLab OAuth login.
Some checks failed
Running Code Coverage / build (20.x) (push) Has been cancelled
Signed-off-by: Asgeir Nilsen <asgeir@twingine.no>
2025-06-14 01:55:42 -07:00
303dfb3253 fix: show referrers pointing to image manifests (#478)
* fix: show referrers pointing to image manifests

See https://github.com/project-zot/zui/issues/476

In previous implementations the referrers tab only showed the referrers returned at image
level, ignoring the referrers returned at manifest level.
This is fine for singlearch images, since they are the same.
For images containing multiple manifests only index referrers were shown.

The current implementation would show both the referrers to the index and the current manifest.

ci: improve e2e job reporting

Signed-off-by: Andrei Aaron <aaaron@luxoft.com>

* fix: the Uses, Used By, Vulnerabilities, Referred By tabs were not refreshed on changing manifest value

Signed-off-by: Andrei Aaron <aaaron@luxoft.com>

---------

Signed-off-by: Andrei Aaron <aaaron@luxoft.com>
2025-03-24 11:25:54 +02:00
203b0973a5 fix: minor issues with handling empty manifest lists and missing platform information (#477)
- fix: do not error in TagDetails if an empty manifest list is returned from the backend
- fix: use '----' as default for missing Os/Arch in drop-down and card (this value is for consistency with other such places where the values are missing)

Signed-off-by: Andrei Aaron <aaaron@luxoft.com>
2025-03-19 10:58:51 +02:00
930ae7e0d3 chore: fix dependabot alerts
fix: Upgrade packages and fix unit tests

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>
Signed-off-by: Raul-Cristian Kele <raulkeleblk@gmail.com>
2024-12-26 00:41:43 -08:00
5918aebbf3 fix: jq argument argfile was deprecated and removed (#467)
Signed-off-by: Andrei Aaron <aaaron@luxoft.com>
2024-12-25 09:20:45 +02:00
508fe324b0 chore: upgrade to actions/upload-artifact@v4
Artifact actions v3 will be closing down by January 30, 2025.

Signed-off-by: Andrei Aaron <aaaron@luxoft.com>
2024-12-19 09:45:14 -08:00
5a9533d95e Adding support for new arches (#449)
Adding support for new architectures such as:
- 64-bit LoongArch
- 64-bit RISC-V

Signed-off-by: Alexander Burmatov <thatman@altlinux.org>
2024-11-15 09:30:47 +02:00
182ef55166 chore: fix dependabot alerts
Signed-off-by: Ramkumar Chinchani <rchincha@cisco.com>
2024-11-13 10:41:29 -08:00
f67c1c8c1e chore: fix dependabot alerts
Signed-off-by: Ramkumar Chinchani <rchincha@cisco.com>
2024-11-08 10:26:06 -08:00
6cadd7feac chore: use our own trivy repository (#447) 2024-11-02 06:58:07 +02:00
7bd1d7dfc7 chore: fix dependabot/snyk alerts (#446)
Signed-off-by: Ramkumar Chinchani <rchincha@cisco.com>
2024-10-31 20:15:29 +02:00
f0bf77a487 Add license scan report and status (#435)
Signed off by: fossabot <badges@fossa.com>
2024-08-17 12:41:58 +03:00
317820926e patch: bump vulnerable dependencies (#442)
Signed-off-by: Raul-Cristian Kele <raulkeleblk@gmail.com>
2024-08-16 16:43:35 +03:00
c78b303ee8 papercut-fix: perform login on enter button press for basic auth (#434)
Signed-off-by: Vishwas Rajashekar <vrajashe@cisco.com>
2024-04-02 15:55:08 +03:00
9de2337809 fix: don't display divider for API Keys menu item when API key is disabled (#433)
Signed-off-by: Vishwas Rajashekar <vrajashe@cisco.com>
2024-03-24 22:26:23 +02:00
c4d595c782 patch: signature display redesign (#427)
Signed-off-by: Raul-Cristian Kele <raulkeleblk@gmail.com>
Signed-off-by: Andreea-Lupu <andreealupu1470@yahoo.com>
Co-authored-by: Raul-Cristian Kele <raulkeleblk@gmail.com>
2024-03-24 22:12:31 +02:00
09ab4474e9 fix(export-cves): use a constant string('vulnerabilities') to set xlsx sheet name (#431)
Some checks failed
Running Code Coverage / build (16.x) (push) Failing after 32s
Signed-off-by: Andreea-Lupu <andreealupu1470@yahoo.com>
2024-03-05 19:25:20 +02:00
177406df41 feat(search-bar): redirect to image view on enter when search maches … (#422)
* feat(search-bar): redirect to image view on enter when search maches a repo:tag

Signed-off-by: Laurentiu Niculae <niculae.laurentiu1@gmail.com>
2024-03-01 12:07:56 +02:00
e2367c2a33 feat: include PkgPath information in image cve list and list export (#428)
Signed-off-by: Vishwas Rajashekar <vrajashe@cisco.com>
2024-02-29 12:23:15 +02:00
33524ce3cc feat: Implement api key management (#403)
Signed-off-by: Raul-Cristian Kele <raulkeleblk@gmail.com>
2024-02-18 15:53:30 +02:00
e037c6c577 feat(cve): filter cves by severity
Signed-off-by: Andreea-Lupu <andreealupu1470@yahoo.com>
2024-02-15 13:17:43 -08:00
c268991495 fix(cve): make cards collapsed by default
Signed-off-by: Andreea-Lupu <andreealupu1470@yahoo.com>
2024-02-14 09:20:52 -08:00
0edfe0f73a feat(cve): add more information
Signed-off-by: Andreea-Lupu <andreealupu1470@yahoo.com>
2024-02-05 10:11:57 -08:00
f4a6030d93 feat(cve): added option to exclude from returned search results a given string (#415)
Signed-off-by: Laurentiu Niculae <niculae.laurentiu1@gmail.com>
2024-01-31 10:28:30 +02:00
9358539e0c fix: remove --or-- divider if social login is not enabled (#418)
Signed-off-by: Andreea-Lupu <andreealupu1470@yahoo.com>
2024-01-30 10:11:38 +02:00
72 changed files with 11956 additions and 6734 deletions

View File

@ -1,10 +0,0 @@
**/.git
**/.svn
**/.hg
**/node_modules
**/.github
README.md
LICENSE
Makefile
**/coverage
**/build

View File

@ -1,54 +0,0 @@
{
"env": {
"browser": true,
"es2021": true,
"jest": true
},
"extends": ["eslint:recommended", "plugin:react/recommended", "plugin:prettier/recommended"],
"overrides": [],
"rules": {
"react/prop-types": "off"
},
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": ["react"],
"settings": {
"react": {
"createClass": "createReactClass", // Regex for Component Factory to use,
// default to "createReactClass"
"pragma": "React", // Pragma to use, default to "React"
"fragment": "Fragment", // Fragment to use (may be a property of <pragma>), default to "Fragment"
"version": "detect", // React version. "detect" automatically picks the version you have installed.
// You can also use `16.0`, `16.3`, etc, if you want to override the detected value.
// It will default to "latest" and warn if missing, and to "detect" in the future
"flowVersion": "0.53" // Flow version
},
"propWrapperFunctions": [
// The names of any function used to wrap propTypes, e.g. `forbidExtraProps`. If this isn't set, any propTypes wrapped in a function will be skipped.
"forbidExtraProps",
{ "property": "freeze", "object": "Object" },
{ "property": "myFavoriteWrapper" },
// for rules that check exact prop wrappers
{ "property": "forbidExtraProps", "exact": true }
],
"componentWrapperFunctions": [
// The name of any function used to wrap components, e.g. Mobx `observer` function. If this isn't set, components wrapped by these functions will be skipped.
"observer", // `property`
{ "property": "styled" }, // `object` is optional
{ "property": "observer", "object": "Mobx" },
{ "property": "observer", "object": "<pragma>" } // sets `object` to whatever value `settings.react.pragma` is set to
],
"formComponents": [
// Components used as alternatives to <form> for forms, eg. <Form endpoint={ url } />
"CustomForm",
{ "name": "Form", "formAttribute": "endpoint" }
],
"linkComponents": [
// Components used as alternatives to <a> for linking, eg. <Link to={ url } />
"Hyperlink",
{ "name": "Link", "linkAttribute": "to" }
]
}
}

View File

@ -23,7 +23,7 @@ jobs:
strategy:
matrix:
node-version: [16.x]
node-version: [20.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:

View File

@ -9,26 +9,27 @@ jobs:
strategy:
matrix:
node-version: [16.x]
node-version: [20.x]
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Set up Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: npm install
run: make install
- name: Run the tests
run: npm test -- --coverage
run: npm run test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v1
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
verbose: true

View File

@ -18,7 +18,7 @@ jobs:
name: Test zui/zot integration
env:
CI: ""
REGISTRY_HOST: "localhost"
REGISTRY_HOST: "127.0.0.1"
REGISTRY_PORT: "8080"
runs-on: ubuntu-latest
@ -32,14 +32,14 @@ jobs:
sudo rm -rf /usr/share/dotnet
- name: Checkout zui repository
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Set up Node.js 16.x
uses: actions/setup-node@v3
- name: Set up Node.js 18.x
uses: actions/setup-node@v4
with:
node-version: 16.x
node-version: 18.x
cache: 'npm'
- name: Build zui
@ -81,7 +81,7 @@ jobs:
- name: Install go
uses: actions/setup-go@v3
with:
go-version: 1.20.x
go-version: 1.22.x
- name: Checkout zot repo
uses: actions/checkout@v3
@ -135,7 +135,8 @@ jobs:
make integration-tests REGISTRY_HOST=$REGISTRY_HOST REGISTRY_PORT=$REGISTRY_PORT
- name: Upload playwright report
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/

View File

@ -6,7 +6,8 @@ all: install audit build
.PHONY: install
install:
npm install --no-audit
npm install --no-audit;\
npx update-browserslist-db@latest
.PHONY: build
build:

View File

@ -1,4 +1,5 @@
# zot UI [![build-test](https://github.com/project-zot/zui/actions/workflows/build-test.yml/badge.svg?branch=main)](https://github.com/project-zot/zui/actions/workflows/build-test.yml) [![codecov.io](http://codecov.io/github/project-zot/zui/coverage.svg?branch=main)](http://codecov.io/github/project-zot/zui?branch=main) [![CodeQL](https://github.com/project-zot/zui/workflows/CodeQL/badge.svg)](https://github.com/project-zot/zui/actions?query=workflow%3ACodeQL)
# zot UI [![build-test](https://github.com/project-zot/zui/actions/workflows/build-test.yml/badge.svg?branch=main)](https://github.com/project-zot/zui/actions/workflows/build-test.yml) [![codecov.io](http://codecov.io/github/project-zot/zui/coverage.svg?branch=main)](http://codecov.io/github/project-zot/zui?branch=main) [![CodeQL](https://github.com/project-zot/zui/workflows/CodeQL/badge.svg)](https://github.com/project-zot/zui/actions?query=workflow%3ACodeQL) [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fproject-zot%2Fzui.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Fproject-zot%2Fzui?ref=badge_shield)
A graphical user interface to interact with a [zot](https://github.com/project-zot/zot) server instance.
Built with [React JS](https://reactjs.org/) and [Material UI](https://mui.com/).
@ -59,3 +60,7 @@ This project was bootstrapped with [Create React App](https://github.com/faceboo
To learn React, check out the [React documentation](https://reactjs.org/).
To learn Material UI, check out the [Material UI Library](https://mui.com/).
## License
[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fproject-zot%2Fzui.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Fproject-zot%2Fzui?ref=badge_large)

108
eslint.config.mjs Normal file
View File

@ -0,0 +1,108 @@
import react from 'eslint-plugin-react';
import globals from 'globals';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import js from '@eslint/js';
import { FlatCompat } from '@eslint/eslintrc';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all
});
export default [
{
files: ['**/*.js', '**/*.jsx'],
ignores: [
'**/.git',
'**/.svn',
'**/.hg',
'**/node_modules',
'**/.github',
'**/README.md',
'**/LICENSE',
'**/Makefile',
'**/coverage',
'**/build'
]
},
...compat.extends('eslint:recommended', 'plugin:react/recommended', 'plugin:prettier/recommended'),
{
plugins: {
react
},
languageOptions: {
globals: {
...globals.browser,
...globals.jest,
...globals.node
},
ecmaVersion: 'latest',
sourceType: 'module'
},
settings: {
react: {
createClass: 'createReactClass',
pragma: 'React',
fragment: 'Fragment',
version: 'detect',
flowVersion: '0.53'
},
propWrapperFunctions: [
'forbidExtraProps',
{
property: 'freeze',
object: 'Object'
},
{
property: 'myFavoriteWrapper'
},
{
property: 'forbidExtraProps',
exact: true
}
],
componentWrapperFunctions: [
'observer',
{
property: 'styled'
},
{
property: 'observer',
object: 'Mobx'
},
{
property: 'observer',
object: '<pragma>'
}
],
formComponents: [
'CustomForm',
{
name: 'Form',
formAttribute: 'endpoint'
}
],
linkComponents: [
'Hyperlink',
{
name: 'Link',
linkAttribute: 'to'
}
]
},
rules: {
'react/prop-types': 'off'
}
}
];

15824
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,58 +3,59 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@emotion/react": "^11.9.3",
"@emotion/styled": "^11.9.3",
"@mui/icons-material": "^5.2.5",
"@mui/lab": "^5.0.0-alpha.89",
"@mui/material": "^5.8.6",
"@mui/styles": "^5.8.6",
"@testing-library/jest-dom": "^5.16.1",
"@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^13.5.0",
"axios": "^0.24.0",
"@adobe/css-tools": "^4.4.1",
"@emotion/react": "^11.13.5",
"@emotion/styled": "^11.13.5",
"@mui/icons-material": "^6.1.10",
"@mui/lab": "^6.0.0-beta.18",
"@mui/material": "^6.1.10",
"@mui/styles": "^6.1.10",
"@mui/x-date-pickers": "^7.23.1",
"axios": "^1.7.8",
"downshift": "^6.1.12",
"export-from-json": "^1.7.3",
"export-from-json": "^1.7.4",
"lodash": "^4.17.21",
"luxon": "^2.5.2",
"markdown-to-jsx": "^7.1.7",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-router-dom": "^6.2.1",
"react-sticky-el": "^2.0.9",
"luxon": "^3.5.0",
"markdown-to-jsx": "^7.6.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.0.2",
"react-sticky-el": "^2.1.1",
"web-vitals": "^2.1.3",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.16.7",
"@playwright/test": "^1.28.1",
"eslint": "^8.23.1",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.31.8",
"prettier": "^2.7.1",
"@babel/runtime": "^7.26.0",
"@playwright/test": "^1.46.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.0",
"babel-jest": "^29.7.0",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-react": "^7.37.2",
"globals": "^14.0.0",
"jest": "^29.7.0",
"prettier": "^3.4.2",
"react-scripts": "^5.0.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --detectOpenHandles",
"test:coverage": "react-scripts test --detectOpenHandles --coverage",
"test": "react-scripts test --detectOpenHandles --max_old_space_size=4096 --transformIgnorePatterns 'node_modules/(?!my-library-dir)/'",
"test:coverage": "react-scripts test --detectOpenHandles --max_old_space_size=4096 --transformIgnorePatterns 'node_modules/(?!my-library-dir)/' --coverage",
"test:ui": "playwright test",
"test:ui-headed": "playwright test --headed --trace on",
"test:ui-debug": "playwright test --trace on",
"test:release": "npm run test && npm run test:ui",
"lint": "eslint -c .eslintrc.json --ext .js,.jsx .",
"lint": "eslint -c eslint.config.mjs",
"lint:fix": "npm run lint -- --fix",
"format": "prettier --write ./**/*.{js,jsx,ts,tsx,css,md,json} --config ./.prettierrc",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",

View File

@ -7,7 +7,6 @@ const { devices } = require('@playwright/test');
*/
// require('dotenv').config();
/**
* @see https://playwright.dev/docs/test-configuration
* @type {import('@playwright/test').PlaywrightTestConfig}
@ -53,7 +52,7 @@ const config = {
use: {
...devices['Desktop Chrome'],
ignoreHTTPSErrors: true
},
}
},
{
@ -61,7 +60,7 @@ const config = {
use: {
...devices['Desktop Firefox'],
ignoreHTTPSErrors: true
},
}
},
{
@ -69,8 +68,8 @@ const config = {
use: {
...devices['Desktop Safari'],
ignoreHTTPSErrors: true
},
},
}
}
/* Test against mobile viewports. */
// {
@ -102,7 +101,7 @@ const config = {
],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
outputDir: 'test-results/',
outputDir: 'test-results/'
/* Run your local dev server before starting the tests */
// webServer: {

View File

@ -1,14 +1,15 @@
import React, { useState } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router';
import { isAuthenticated } from 'utilities/authUtilities';
import { isAuthenticated, isApiKeyEnabled } from 'utilities/authUtilities';
import { AuthWrapper } from 'utilities/AuthWrapper';
import HomePage from './pages/HomePage';
import LoginPage from './pages/LoginPage';
import { AuthWrapper } from 'utilities/AuthWrapper';
import RepoPage from 'pages/RepoPage';
import TagPage from 'pages/TagPage';
import ExplorePage from 'pages/ExplorePage';
import UserManagementPage from 'pages/UserManagementPage';
import './App.css';
@ -25,6 +26,7 @@ function App() {
<Route path="/explore" element={<ExplorePage />} />
<Route path="/image/:name" element={<RepoPage />} />
<Route path="/image/:reponame/tag/:tag" element={<TagPage />} />
{isApiKeyEnabled() && <Route path="/user/apikey" element={<UserManagementPage />} />}
<Route path="*" element={<Navigate to="/home" />} />
</Route>
<Route element={<AuthWrapper isLoggedIn={!isLoggedIn} redirect="/" />}>

View File

@ -1,17 +1,17 @@
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { api } from 'api';
import Explore from 'components/Explore/Explore';
import React from 'react';
import { createSearchParams, MemoryRouter } from 'react-router-dom';
import { createSearchParams, MemoryRouter } from 'react-router';
import filterConstants from 'utilities/filterConstants.js';
import { sortByCriteria } from 'utilities/sortCriteria.js';
import MockThemeProvider from '__mocks__/MockThemeProvider';
// router mock
const mockedUsedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useNavigate: () => mockedUsedNavigate
}));
@ -38,7 +38,7 @@ const mockImageList = {
NewestImage: {
Tag: 'latest',
Description: 'w',
IsSigned: false,
SignatureInfo: [],
Licenses: '',
Vendor: '',
Labels: '',
@ -63,7 +63,18 @@ const mockImageList = {
NewestImage: {
Tag: 'latest',
Description: '',
IsSigned: true,
SignatureInfo: [
{
Tool: 'cosign',
IsTrusted: false,
Author: ''
},
{
Tool: 'notation',
IsTrusted: false,
Author: ''
}
],
Licenses: '',
Vendor: '',
Labels: '',
@ -88,7 +99,18 @@ const mockImageList = {
NewestImage: {
Tag: 'latest',
Description: '',
IsSigned: true,
SignatureInfo: [
{
Tool: 'cosign',
IsTrusted: true,
Author: ''
},
{
Tool: 'notation',
IsTrusted: true,
Author: ''
}
],
Licenses: '',
Vendor: '',
Labels: '',
@ -113,7 +135,18 @@ const mockImageList = {
NewestImage: {
Tag: 'latest',
Description: '',
IsSigned: true,
SignatureInfo: [
{
Tool: 'cosign',
IsTrusted: true,
Author: ''
},
{
Tool: 'notation',
IsTrusted: true,
Author: ''
}
],
Licenses: '',
Vendor: '',
Labels: '',
@ -138,7 +171,18 @@ const mockImageList = {
NewestImage: {
Tag: 'latest',
Description: '',
IsSigned: true,
SignatureInfo: [
{
Tool: 'cosign',
IsTrusted: true,
Author: ''
},
{
Tool: 'notation',
IsTrusted: true,
Author: ''
}
],
Licenses: '',
Vendor: '',
Labels: '',
@ -167,7 +211,18 @@ const mockImageList = {
NewestImage: {
Tag: 'latest',
Description: '',
IsSigned: true,
SignatureInfo: [
{
Tool: 'cosign',
IsTrusted: true,
Author: ''
},
{
Tool: 'notation',
IsTrusted: true,
Author: ''
}
],
Licenses: '',
Vendor: '',
Labels: '',
@ -192,7 +247,18 @@ const mockImageList = {
NewestImage: {
Tag: 'latest',
Description: '',
IsSigned: true,
SignatureInfo: [
{
Tool: 'cosign',
IsTrusted: true,
Author: 'author1'
},
{
Tool: 'notation',
IsTrusted: true,
Author: 'author2'
}
],
Licenses: '',
Vendor: '',
Labels: '',
@ -225,7 +291,7 @@ const filteredMockImageListWindows = () => {
};
const filteredMockImageListSigned = () => {
const filteredRepos = mockImageList.GlobalSearch.Repos.filter((r) => r.NewestImage.IsSigned);
const filteredRepos = mockImageList.GlobalSearch.Repos.filter((r) => r.NewestImage.SignatureInfo?.length > 0);
return {
GlobalSearch: {
Page: { TotalCount: 6, ItemCount: 6 },
@ -273,7 +339,22 @@ describe('Explore component', () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageList } });
render(<StateExploreWrapper />);
expect(await screen.findAllByTestId('unverified-icon')).toHaveLength(1);
expect(await screen.findAllByTestId('verified-icon')).toHaveLength(6);
expect(await screen.findAllByTestId('untrusted-icon')).toHaveLength(2);
expect(await screen.findAllByTestId('verified-icon')).toHaveLength(10);
const allUntrustedSignaturesIcons = await screen.findAllByTestId('untrusted-icon');
fireEvent.mouseOver(allUntrustedSignaturesIcons[0]);
expect(await screen.findByText('Signed-by: Unknown')).toBeInTheDocument();
const allTrustedSignaturesIcons = await screen.findAllByTestId('verified-icon');
fireEvent.mouseOver(allTrustedSignaturesIcons[8]);
expect(await screen.findByText('Tool: cosign')).toBeInTheDocument();
expect(await screen.findByText('Signed-by: author1')).toBeInTheDocument();
fireEvent.mouseOver(allTrustedSignaturesIcons[9]);
expect(await screen.findByText('Tool: notation')).toBeInTheDocument();
expect(await screen.findByText('Signed-by: author2')).toBeInTheDocument();
const allNoSignedIcons = await screen.findAllByTestId('unverified-icon');
fireEvent.mouseOver(allNoSignedIcons[0]);
expect(await screen.findByText('Not signed')).toBeInTheDocument();
});
it('renders vulnerability icons', async () => {

View File

@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react';
import ExplorePage from 'pages/ExplorePage';
import React from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { BrowserRouter, Route, Routes } from 'react-router';
jest.mock(
'components/Explore/Explore',

View File

@ -0,0 +1,40 @@
import { render, screen, fireEvent } from '@testing-library/react';
import UserAccountMenu from 'components/Header/UserAccountMenu';
import React from 'react';
const mockIsApiKeyEnabled = jest.fn();
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useNavigate: () => {}
}));
jest.mock('../../utilities/authUtilities', () => ({
isApiKeyEnabled: () => {
return mockIsApiKeyEnabled();
},
getLoggedInUser: () => {
return 'jest-user';
},
logoutUser: () => {}
}));
describe('Account Menu', () => {
it('displays Api Keys menu item with its divider when the API Keys config is enabled', async () => {
mockIsApiKeyEnabled.mockReturnValue(true);
render(<UserAccountMenu />);
const userIconButton = await screen.getByTestId('user-icon-header-button');
fireEvent.click(userIconButton);
expect(await screen.queryByTestId('api-keys-menu-item')).toBeInTheDocument();
expect(await screen.queryByTestId('api-keys-menu-item-divider')).toBeInTheDocument();
});
it('does not display Api Keys menu item and divider when the API Keys config is disabled', async () => {
mockIsApiKeyEnabled.mockReturnValue(false);
render(<UserAccountMenu />);
const userIconButton = await screen.getByTestId('user-icon-header-button');
fireEvent.click(userIconButton);
expect(await screen.queryByTestId('api-keys-menu-item')).not.toBeInTheDocument();
expect(await screen.queryByTestId('api-keys-menu-item-divider')).not.toBeInTheDocument();
});
});

View File

@ -2,14 +2,14 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { api } from 'api';
import Home from 'components/Home/Home';
import React from 'react';
import { createSearchParams } from 'react-router-dom';
import { createSearchParams } from 'react-router';
import { sortByCriteria } from 'utilities/sortCriteria';
import MockThemeProvider from '__mocks__/MockThemeProvider';
// useNavigate mock
const mockedUsedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useNavigate: () => mockedUsedNavigate
}));
@ -32,7 +32,7 @@ const mockImageList = {
NewestImage: {
Tag: 'latest',
Description: 'w',
IsSigned: false,
SignatureInfo: [],
Licenses: '',
Vendor: '',
Labels: '',
@ -49,7 +49,18 @@ const mockImageList = {
NewestImage: {
Tag: 'latest',
Description: '',
IsSigned: true,
SignatureInfo: [
{
Tool: 'cosign',
IsTrusted: true,
Author: ''
},
{
Tool: 'notation',
IsTrusted: true,
Author: ''
}
],
Licenses: '',
Vendor: '',
Labels: '',
@ -66,7 +77,18 @@ const mockImageList = {
NewestImage: {
Tag: 'latest',
Description: '',
IsSigned: true,
SignatureInfo: [
{
Tool: 'cosign',
IsTrusted: true,
Author: ''
},
{
Tool: 'notation',
IsTrusted: true,
Author: ''
}
],
Licenses: '',
Vendor: '',
Labels: '',
@ -91,7 +113,7 @@ const mockImageListRecent = {
NewestImage: {
Tag: 'latest',
Description: 'w',
IsSigned: false,
SignatureInfo: [],
Licenses: '',
Vendor: '',
Labels: '',
@ -108,7 +130,18 @@ const mockImageListRecent = {
NewestImage: {
Tag: 'latest',
Description: '',
IsSigned: true,
SignatureInfo: [
{
Tool: 'cosign',
IsTrusted: true,
Author: ''
},
{
Tool: 'notation',
IsTrusted: true,
Author: ''
}
],
Licenses: '',
Vendor: '',
Labels: '',
@ -230,7 +263,7 @@ describe('Home component', () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageListRecent } });
render(<HomeWrapper />);
expect(await screen.findAllByTestId('unverified-icon')).toHaveLength(4);
expect(await screen.findAllByTestId('verified-icon')).toHaveLength(5);
expect(await screen.findAllByTestId('verified-icon')).toHaveLength(10);
});
it('renders vulnerability icons', async () => {

View File

@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react';
import HomePage from 'pages/HomePage';
import React from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { BrowserRouter, Route, Routes } from 'react-router';
jest.mock(
'components/Home/Home',

View File

@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react';
import LoginPage from 'pages/LoginPage';
import React from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { BrowserRouter, Route, Routes } from 'react-router';
import MockThemeProvider from '__mocks__/MockThemeProvider';
it('renders the signin presentation component and signin components if auth enabled', () => {

View File

@ -7,13 +7,13 @@ import userEvent from '@testing-library/user-event';
const mockMgmtResponse = {
distSpecVersion: '1.1.0-dev',
binaryType: '-apikey-lint-metrics-mgmt-scrub-search-sync-ui-userprefs',
http: { auth: { htpasswd: {} } }
http: { auth: { htpasswd: {}, openid: { providers: { github: {} } } } }
};
// useNavigate mock
const mockedUsedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useNavigate: () => mockedUsedNavigate
}));
@ -24,14 +24,14 @@ afterEach(() => {
describe('Signin component automatic navigation', () => {
it('navigates to homepage when user is already logged in', async () => {
render(<SignIn isAuthEnabled={true} setIsAuthEnabled={() => {}} isLoggedIn={true} setIsLoggedIn={() => {}} />);
render(<SignIn isLoggedIn={true} setIsLoggedIn={() => {}} />);
await expect(mockedUsedNavigate).toHaveBeenCalledWith('/home');
});
it('navigates to homepage when auth is disabled', async () => {
// mock request to check auth
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { http: {} } });
render(<SignIn isAuthEnabled={true} setIsAuthEnabled={() => {}} isLoggedIn={false} setIsLoggedIn={() => {}} />);
render(<SignIn isLoggedIn={false} setIsLoggedIn={() => {}} />);
await waitFor(() => {
expect(mockedUsedNavigate).toHaveBeenCalledWith('/home');
});
@ -48,49 +48,180 @@ describe('Sign in form', () => {
});
it('should change username and password values on user input', async () => {
render(<SignIn isAuthEnabled={true} setIsAuthEnabled={() => {}} isLoggedIn={false} setIsLoggedIn={() => {}} />);
render(<SignIn isLoggedIn={false} setIsLoggedIn={() => {}} />);
const usernameInput = await screen.findByLabelText(/^Username/i);
const passwordInput = await screen.findByLabelText(/^Enter Password/i);
fireEvent.change(usernameInput, { target: { value: 'test' } });
fireEvent.change(passwordInput, { target: { value: 'test' } });
expect(usernameInput).toHaveValue('test');
expect(passwordInput).toHaveValue('test');
expect(screen.getByTestId('openid-divider')).toBeInTheDocument();
});
it('should display error if username and password values are empty after change', async () => {
render(<SignIn isAuthEnabled={true} setIsAuthEnabled={() => {}} isLoggedIn={false} setIsLoggedIn={() => {}} />);
render(<SignIn isLoggedIn={false} setIsLoggedIn={() => {}} />);
const usernameInput = await screen.findByLabelText(/^Username/i);
const passwordInput = await screen.findByLabelText(/^Enter Password/i);
userEvent.click(usernameInput);
userEvent.type(usernameInput, 't');
userEvent.type(usernameInput, '{backspace}');
userEvent.click(passwordInput);
userEvent.type(passwordInput, 't');
userEvent.type(passwordInput, '{backspace}');
await fireEvent.click(usernameInput);
await userEvent.type(usernameInput, 't');
await userEvent.type(usernameInput, '{backspace}');
await fireEvent.click(passwordInput);
await userEvent.type(passwordInput, 't');
await userEvent.type(passwordInput, '{backspace}');
const usernameError = await screen.findByText(/enter a username/i);
const passwordError = await screen.findByText(/enter a password/i);
await waitFor(() => expect(usernameError).toBeInTheDocument());
await waitFor(() => expect(passwordError).toBeInTheDocument());
});
it('should log in the user and navigate to homepage if login is successful', async () => {
render(<SignIn isAuthEnabled={true} setIsAuthEnabled={() => {}} isLoggedIn={false} setIsLoggedIn={() => {}} />);
const submitButton = await screen.findByText('Continue');
it('should log in the user and navigate to homepage if login is successful using button', async () => {
render(<SignIn isLoggedIn={false} setIsLoggedIn={() => {}} />);
const usernameInput = await screen.findByLabelText(/^Username/i);
const passwordInput = await screen.findByLabelText(/^Enter Password/i);
await userEvent.type(usernameInput, 'test');
await userEvent.type(passwordInput, 'test');
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: {} } });
const submitButton = await screen.findByText('Continue');
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockedUsedNavigate).toHaveBeenCalledWith('/home');
});
});
it('should should display login error if login not successful', async () => {
render(<SignIn isAuthEnabled={true} setIsAuthEnabled={() => {}} isLoggedIn={false} setIsLoggedIn={() => {}} />);
const submitButton = await screen.findByText('Continue');
jest.spyOn(api, 'get').mockRejectedValue({ status: 401, data: {} });
fireEvent.click(submitButton);
const errorDisplay = await screen.findByText(/Authentication Failed/i);
it('should display an error if username is blank and login is attempted using button', async () => {
render(<SignIn isLoggedIn={false} setIsLoggedIn={() => {}} />);
const passwordInput = await screen.findByLabelText(/^Enter Password/i);
await userEvent.type(passwordInput, 'test');
const submitButton = await screen.findByTestId('basic-auth-submit-btn');
await fireEvent.click(submitButton);
await waitFor(() => expect(screen.queryByText(/enter a username/i)).toBeInTheDocument());
await waitFor(() => expect(screen.queryByText(/enter a password/i)).not.toBeInTheDocument());
await waitFor(() => {
expect(errorDisplay).toBeInTheDocument();
expect(mockedUsedNavigate).not.toHaveBeenCalled();
});
});
it('should display an error if password is blank and login is attempted using button', async () => {
render(<SignIn isLoggedIn={false} setIsLoggedIn={() => {}} />);
const usernameInput = await screen.findByLabelText(/^Username/i);
userEvent.type(usernameInput, 'test');
const submitButton = await screen.findByTestId('basic-auth-submit-btn');
fireEvent.click(submitButton);
await waitFor(() => expect(screen.queryByText(/enter a username/i)).not.toBeInTheDocument());
await waitFor(() => expect(screen.queryByText(/enter a password/i)).toBeInTheDocument());
await waitFor(() => {
expect(mockedUsedNavigate).not.toHaveBeenCalled();
});
});
it('should display an error if username and password are both blank and login is attempted using button', async () => {
render(<SignIn isLoggedIn={false} setIsLoggedIn={() => {}} />);
const submitButton = await screen.findByTestId('basic-auth-submit-btn');
fireEvent.click(submitButton);
await waitFor(() => expect(screen.queryByText(/enter a username/i)).toBeInTheDocument());
await waitFor(() => expect(screen.queryByText(/enter a password/i)).toBeInTheDocument());
await waitFor(() => {
expect(mockedUsedNavigate).not.toHaveBeenCalled();
});
});
it('should log in the user and navigate to homepage if login is successful using enter key on username field', async () => {
render(<SignIn isLoggedIn={false} setIsLoggedIn={() => {}} />);
const usernameInput = await screen.findByLabelText(/^Username/i);
const passwordInput = await screen.findByLabelText(/^Enter Password/i);
await userEvent.type(usernameInput, 'test');
await userEvent.type(passwordInput, 'test');
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: {} } });
userEvent.type(usernameInput, '{enter}');
await waitFor(() => {
expect(mockedUsedNavigate).toHaveBeenCalledWith('/home');
});
});
it('should log in the user and navigate to homepage if login is successful using enter key on password field', async () => {
render(<SignIn isLoggedIn={false} setIsLoggedIn={() => {}} />);
const usernameInput = await screen.findByLabelText(/^Username/i);
const passwordInput = await screen.findByLabelText(/^Enter Password/i);
await userEvent.type(usernameInput, 'test');
await userEvent.type(passwordInput, 'test');
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: {} } });
await userEvent.type(passwordInput, '{enter}');
await waitFor(() => {
expect(mockedUsedNavigate).toHaveBeenCalledWith('/home');
});
});
it('should display an error if username is blank and login is attempted using enter key', async () => {
render(<SignIn isLoggedIn={false} setIsLoggedIn={() => {}} />);
const passwordInput = await screen.findByLabelText(/^Enter Password/i);
userEvent.type(passwordInput, 'test');
userEvent.type(passwordInput, '{enter}');
await waitFor(() => expect(screen.queryByText(/enter a username/i)).toBeInTheDocument());
await waitFor(() => expect(screen.queryByText(/enter a password/i)).not.toBeInTheDocument());
await waitFor(() => {
expect(mockedUsedNavigate).not.toHaveBeenCalled();
});
});
it('should display an error if password is blank and login is attempted using enter key', async () => {
render(<SignIn isLoggedIn={false} setIsLoggedIn={() => {}} />);
const usernameInput = await screen.findByLabelText(/^Username/i);
userEvent.type(usernameInput, 'test');
userEvent.type(usernameInput, '{enter}');
await waitFor(() => expect(screen.queryByText(/enter a username/i)).not.toBeInTheDocument());
await waitFor(() => expect(screen.queryByText(/enter a password/i)).toBeInTheDocument());
await waitFor(() => {
expect(mockedUsedNavigate).not.toHaveBeenCalled();
});
});
it('should display an error if username and password are both blank and login is attempted using enter key', async () => {
render(<SignIn isLoggedIn={false} setIsLoggedIn={() => {}} />);
const passwordInput = await screen.findByLabelText(/^Enter Password/i);
userEvent.type(passwordInput, '{enter}');
await waitFor(() => expect(screen.queryByText(/enter a username/i)).toBeInTheDocument());
await waitFor(() => expect(screen.queryByText(/enter a password/i)).toBeInTheDocument());
await waitFor(() => {
expect(mockedUsedNavigate).not.toHaveBeenCalled();
});
});
it('should should display login error if login not successful', async () => {
render(<SignIn isLoggedIn={false} setIsLoggedIn={() => {}} />);
const usernameInput = await screen.findByLabelText(/^Username/i);
const passwordInput = await screen.findByLabelText(/^Enter Password/i);
await userEvent.type(usernameInput, 'test');
await userEvent.type(passwordInput, 'test');
jest.spyOn(api, 'get').mockRejectedValue({ status: 401, data: {} });
const submitButton = await screen.findByText('Continue');
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.queryByText(/Authentication Failed/i)).toBeInTheDocument();
});
await waitFor(() => {
expect(mockedUsedNavigate).not.toHaveBeenCalled();
});
});
});

View File

@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import RepoDetails from 'components/Repo/RepoDetails';
import React from 'react';
import { api } from 'api';
import { createSearchParams } from 'react-router-dom';
import { createSearchParams } from 'react-router';
import MockThemeProvider from '__mocks__/MockThemeProvider';
import userEvent from '@testing-library/user-event';
@ -22,8 +22,8 @@ const mockUseLocationValue = {
const mockUseNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useParams: () => {
return { name: 'test' };
},
@ -51,6 +51,18 @@ const mockRepoDetailsData = {
NewestImage: {
RepoName: 'mongo',
IsSigned: true,
SignatureInfo: [
{
Tool: 'cosign',
IsTrusted: true,
Author: 'author1'
},
{
Tool: 'notation',
IsTrusted: true,
Author: 'author2'
}
],
Vulnerabilities: {
MaxSeverity: 'CRITICAL',
Count: 15
@ -285,6 +297,20 @@ describe('Repo details component', () => {
expect(await screen.findAllByTestId('high-vulnerability-icon')).toHaveLength(1);
});
it('renders signature icons', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockRepoDetailsData } });
render(<RepoDetailsThemeWrapper />);
expect(await screen.findAllByTestId('verified-icon')).toHaveLength(2);
const allTrustedSignaturesIcons = await screen.findAllByTestId('verified-icon');
fireEvent.mouseOver(allTrustedSignaturesIcons[0]);
expect(await screen.findByText('Tool: cosign')).toBeInTheDocument();
expect(await screen.findByText('Signed-by: author1')).toBeInTheDocument();
fireEvent.mouseOver(allTrustedSignaturesIcons[1]);
expect(await screen.findByText('Tool: notation')).toBeInTheDocument();
expect(await screen.findByText('Signed-by: author2')).toBeInTheDocument();
});
it("should log error if data can't be fetched", async () => {
jest.spyOn(api, 'get').mockRejectedValue({ status: 500, data: {} });
const error = jest.spyOn(console, 'error').mockImplementation(() => {});

View File

@ -1,11 +1,11 @@
import { render, screen } from '@testing-library/react';
import RepoPage from 'pages/RepoPage';
import React from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { BrowserRouter, Route, Routes } from 'react-router';
import MockThemeProvider from '__mocks__/MockThemeProvider';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useLocation: () => ({
pathname: 'localhost:3000/image/test',
state: { lastDate: '' }

View File

@ -13,8 +13,8 @@ const TagsThemeWrapper = () => {
};
const mockedUsedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useNavigate: () => mockedUsedNavigate
}));
@ -79,17 +79,17 @@ describe('Tags component', () => {
await waitFor(() => expect(screen.queryByText(/OS\/ARCH/i)).not.toBeInTheDocument());
});
it('should see delete tag button and its dialog', async () => {
render(<TagsThemeWrapper />);
const deleteBtn = await screen.findAllByTestId('DeleteIcon');
fireEvent.click(deleteBtn[0]);
expect(await screen.findByTestId('delete-dialog')).toBeInTheDocument();
const confirmBtn = await screen.findByTestId('confirm-delete');
expect(confirmBtn).toBeInTheDocument();
fireEvent.click(confirmBtn);
expect(await screen.findByTestId('confirm-delete')).toBeInTheDocument();
expect(await screen.findByTestId('cancel-delete')).toBeInTheDocument();
});
// it('should see delete tag button and its dialog', async () => {
// render(<TagsThemeWrapper />);
// const deleteBtn = await screen.findAllByTestId('DeleteIcon');
// fireEvent.click(deleteBtn[0]);
// expect(await screen.findByTestId('delete-dialog')).toBeInTheDocument();
// const confirmBtn = await screen.findByTestId('confirm-delete');
// expect(confirmBtn).toBeInTheDocument();
// fireEvent.click(confirmBtn);
// expect(await screen.findByTestId('confirm-delete')).toBeInTheDocument();
// expect(await screen.findByTestId('cancel-delete')).toBeInTheDocument();
// });
it('should navigate to tag page details when tag is clicked', async () => {
render(<TagsThemeWrapper />);
@ -127,9 +127,9 @@ describe('Tags component', () => {
render(<TagsThemeWrapper />);
const selectFilter = await screen.findByText('Newest');
expect(selectFilter).toBeInTheDocument();
userEvent.click(selectFilter);
await userEvent.click(selectFilter);
const newOption = await screen.findByText('A - Z');
userEvent.click(newOption);
await userEvent.click(newOption);
expect(await screen.findByText('A - Z')).toBeInTheDocument();
expect(await screen.queryByText('Newest')).not.toBeInTheDocument();
});

View File

@ -5,8 +5,8 @@ import PreviewCard from 'components/Shared/PreviewCard';
// usenavigate mock
const mockedUsedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useNavigate: () => mockedUsedNavigate
}));
@ -32,7 +32,7 @@ describe('Preview card component', () => {
render(<PreviewCard name={mockImage.name} lastUpdated={mockImage.lastUpdated} />);
const cardTitle = await screen.findByText('alpine');
expect(cardTitle).toBeInTheDocument();
userEvent.click(cardTitle);
await userEvent.click(cardTitle);
expect(mockedUsedNavigate).toBeCalledWith(`/image/${mockImage.name}`);
});
});

View File

@ -2,13 +2,13 @@ import { render, screen, fireEvent } from '@testing-library/react';
import React from 'react';
import userEvent from '@testing-library/user-event';
import RepoCard from 'components/Shared/RepoCard';
import { createSearchParams } from 'react-router-dom';
import { createSearchParams } from 'react-router';
import MockThemeProvider from '__mocks__/MockThemeProvider';
// usenavigate mock
const mockedUsedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useNavigate: () => mockedUsedNavigate
}));
@ -77,7 +77,7 @@ describe('Repo card component', () => {
render(<RepoCardWrapper image={mockImage} />);
const cardTitle = await screen.findByText('alpine');
expect(cardTitle).toBeInTheDocument();
userEvent.click(cardTitle);
await userEvent.click(cardTitle);
expect(mockedUsedNavigate).toBeCalledWith(`/image/${mockImage.name}`);
});
@ -85,7 +85,7 @@ describe('Repo card component', () => {
render(<RepoCardWrapper image={{ ...mockImage, lastUpdated: '' }} />);
const cardTitle = await screen.findByText('alpine');
expect(cardTitle).toBeInTheDocument();
userEvent.click(cardTitle);
await userEvent.click(cardTitle);
expect(mockedUsedNavigate).toBeCalledWith(`/image/${mockImage.name}`);
expect(await screen.findByText(/timestamp n\/a/i)).toBeInTheDocument();
});

View File

@ -2,13 +2,13 @@ import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { api } from 'api';
import SearchSuggestion from 'components/Header/SearchSuggestion';
import { MemoryRouter } from 'react-router-dom';
import { MemoryRouter } from 'react-router';
import React from 'react';
// router mock
const mockedUsedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useNavigate: () => mockedUsedNavigate
}));

View File

@ -2,7 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react';
import { api } from 'api';
import DependsOn from 'components/Tag/Tabs/DependsOn';
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { BrowserRouter, Routes, Route } from 'react-router';
import MockThemeProvider from '__mocks__/MockThemeProvider';
const mockDependenciesList = {
@ -65,8 +65,8 @@ const RouterDependsWrapper = () => {
// useNavigate mock
const mockedUsedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useNavigate: () => mockedUsedNavigate
}));

View File

@ -2,7 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react';
import { api } from 'api';
import IsDependentOn from 'components/Tag/Tabs/IsDependentOn';
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { BrowserRouter, Routes, Route } from 'react-router';
import MockThemeProvider from '__mocks__/MockThemeProvider';
const mockDependentsList = {
@ -65,8 +65,8 @@ const RouterDependsWrapper = () => {
// useNavigate mock
const mockedUsedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useNavigate: () => mockedUsedNavigate
}));

View File

@ -40,8 +40,8 @@ const mockReferrersList = [
// useNavigate mock
const mockedUsedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useNavigate: () => mockedUsedNavigate
}));
@ -71,7 +71,7 @@ describe('Referred by tab', () => {
).toBeInTheDocument();
await userEvent.click(firstDigest);
expect(
await screen.findByText(/sha256:be7a3d01c35a2cf53c502e9dc50cdf36b15d9361c81c63bf319f1d5cbe44ab7c/i)
await screen.queryByText(/sha256:be7a3d01c35a2cf53c502e9dc50cdf36b15d9361c81c63bf319f1d5cbe44ab7c/i)
).not.toBeInTheDocument();
});

View File

@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event';
import { api } from 'api';
import TagDetails from 'components/Tag/TagDetails';
import MockThemeProvider from '__mocks__/MockThemeProvider';
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom';
import { BrowserRouter, Routes, Route, useLocation } from 'react-router';
const TagDetailsThemeWrapper = () => {
return (
@ -174,7 +174,20 @@ const mockImage = {
MaxSeverity: 'CRITICAL',
Count: 10
},
Vendor: 'CentOS'
Vendor: 'CentOS',
IsSigned: true,
SignatureInfo: [
{
Tool: 'cosign',
IsTrusted: true,
Author: 'author1'
},
{
Tool: 'notation',
IsTrusted: true,
Author: 'author2'
}
]
}
};
@ -842,8 +855,8 @@ Object.assign(navigator, {
}
});
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useParams: () => {
return { name: 'test', tag: '1.0.1' };
},
@ -963,6 +976,20 @@ describe('Tags details', () => {
expect(await screen.findByTestId('high-vulnerability-icon')).toBeInTheDocument();
});
it('renders signature icons', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImage } });
render(<TagDetailsThemeWrapper />);
expect(await screen.findAllByTestId('verified-icon')).toHaveLength(2);
const allTrustedSignaturesIcons = await screen.findAllByTestId('verified-icon');
fireEvent.mouseOver(allTrustedSignaturesIcons[0]);
expect(await screen.findByText('Tool: cosign')).toBeInTheDocument();
expect(await screen.findByText('Signed-by: author1')).toBeInTheDocument();
fireEvent.mouseOver(allTrustedSignaturesIcons[1]);
expect(await screen.findByText('Tool: notation')).toBeInTheDocument();
expect(await screen.findByText('Signed-by: author2')).toBeInTheDocument();
});
it('should copy the docker pull string to clipboard', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImage } });
render(<TagDetailsThemeWrapper />);

View File

@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react';
import TagPage from 'pages/TagPage';
import React from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { BrowserRouter, Route, Routes } from 'react-router';
afterEach(() => {
// restore the spy created with spyOn

View File

@ -1,10 +1,10 @@
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { render, screen, waitFor, within, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import MockThemeProvider from '__mocks__/MockThemeProvider';
import { api } from 'api';
import VulnerabilitiesDetails from 'components/Tag/Tabs/VulnerabilitiesDetails';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { MemoryRouter } from 'react-router';
jest.mock('xlsx');
@ -18,17 +18,17 @@ const StateVulnerabilitiesWrapper = () => {
);
};
const mockCVEList = {
const simpleMockCVEList = {
CVEListForImage: {
Tag: '',
Page: { ItemCount: 20, TotalCount: 20 },
Page: { ItemCount: 2, TotalCount: 2 },
Summary: {
Count: 5,
UnknownCount: 1,
LowCount: 1,
Count: 2,
UnknownCount: 0,
LowCount: 0,
MediumCount: 1,
HighCount: 1,
CriticalCount: 1,
HighCount: 0,
CriticalCount: 1
},
CVEList: [
{
@ -39,6 +39,54 @@ const mockCVEList = {
PackageList: [
{
Name: 'perl-base',
PackagePath: 'Not Specified',
InstalledVersion: '5.30.0-9ubuntu0.2',
FixedVersion: 'Not Specified'
}
]
},
{
Id: 'CVE-2016-1000027',
Title: 'spring: HttpInvokerServiceExporter readRemoteInvocation method untrusted java deserialization',
Description:
"Pivotal Spring Framework through 5.3.16 suffers from a potential remote code execution (RCE) issue if used for Java deserialization of untrusted data. Depending on how the library is implemented within a product, this issue may or not occur, and authentication may be required. NOTE: the vendor's position is that untrusted data is not an intended use case. The product's behavior will not be changed because some users rely on deserialization of trusted data.",
Severity: 'CRITICAL',
Reference: 'https://avd.aquasec.com/nvd/cve-2016-1000027',
PackageList: [
{
Name: 'org.springframework:spring-web',
PackagePath: 'usr/local/tomcat/webapps/spring4shell.war/WEB-INF/lib/spring-web-5.3.15.jar',
InstalledVersion: '5.3.15',
FixedVersion: '6.0.0'
}
]
}
]
}
};
const mockCVEList = {
CVEListForImage: {
Tag: '',
Page: { ItemCount: 20, TotalCount: 20 },
Summary: {
Count: 5,
UnknownCount: 1,
LowCount: 1,
MediumCount: 1,
HighCount: 1,
CriticalCount: 1
},
CVEList: [
{
Id: 'CVE-2020-16156',
Title: 'perl-CPAN: Bypass of verification of signatures in CHECKSUMS files',
Description: 'CPAN 2.28 allows Signature Verification Bypass.',
Severity: 'MEDIUM',
PackageList: [
{
Name: 'perl-base',
PackagePath: 'Not Specified',
InstalledVersion: '5.30.0-9ubuntu0.2',
FixedVersion: 'Not Specified'
}
@ -54,26 +102,31 @@ const mockCVEList = {
PackageList: [
{
Name: 'krb5-locales',
PackagePath: 'Not Specified',
InstalledVersion: '1.17-6ubuntu4.1',
FixedVersion: 'Not Specified'
},
{
Name: 'libgssapi-krb5-2',
PackagePath: 'Not Specified',
InstalledVersion: '1.17-6ubuntu4.1',
FixedVersion: 'Not Specified'
},
{
Name: 'libk5crypto3',
PackagePath: 'Not Specified',
InstalledVersion: '1.17-6ubuntu4.1',
FixedVersion: 'Not Specified'
},
{
Name: 'libkrb5-3',
PackagePath: 'Not Specified',
InstalledVersion: '1.17-6ubuntu4.1',
FixedVersion: 'Not Specified'
},
{
Name: 'libkrb5support0',
PackagePath: 'Not Specified',
InstalledVersion: '1.17-6ubuntu4.1',
FixedVersion: 'Not Specified'
}
@ -88,6 +141,7 @@ const mockCVEList = {
PackageList: [
{
Name: 'libgnutls30',
PackagePath: 'Not Specified',
InstalledVersion: '3.6.13-2ubuntu1.6',
FixedVersion: '3.6.13-2ubuntu1.7'
}
@ -102,6 +156,7 @@ const mockCVEList = {
PackageList: [
{
Name: 'libpcre2-8-0',
PackagePath: 'Not Specified',
InstalledVersion: '10.34-7',
FixedVersion: 'Not Specified'
}
@ -116,6 +171,7 @@ const mockCVEList = {
PackageList: [
{
Name: 'libsqlite3-0',
PackagePath: 'Not Specified',
InstalledVersion: '3.31.1-4ubuntu0.3',
FixedVersion: '3.31.1-4ubuntu0.4'
}
@ -130,6 +186,7 @@ const mockCVEList = {
PackageList: [
{
Name: 'libpcre3',
PackagePath: 'Not Specified',
InstalledVersion: '2:8.39-12ubuntu0.1',
FixedVersion: 'Not Specified'
}
@ -144,6 +201,7 @@ const mockCVEList = {
PackageList: [
{
Name: 'libsqlite3-0',
PackagePath: 'Not Specified',
InstalledVersion: '3.31.1-4ubuntu0.3',
FixedVersion: '3.31.1-4ubuntu0.4'
}
@ -158,11 +216,13 @@ const mockCVEList = {
PackageList: [
{
Name: 'login',
PackagePath: 'Not Specified',
InstalledVersion: '1:4.8.1-1ubuntu5.20.04.2',
FixedVersion: 'Not Specified'
},
{
Name: 'passwd',
PackagePath: 'Not Specified',
InstalledVersion: '1:4.8.1-1ubuntu5.20.04.2',
FixedVersion: 'Not Specified'
}
@ -177,6 +237,7 @@ const mockCVEList = {
PackageList: [
{
Name: 'libgmp10',
PackagePath: 'Not Specified',
InstalledVersion: '2:6.2.0+dfsg-4',
FixedVersion: 'Not Specified'
}
@ -191,6 +252,7 @@ const mockCVEList = {
PackageList: [
{
Name: 'libgnutls30',
PackagePath: 'Not Specified',
InstalledVersion: '3.6.13-2ubuntu1.6',
FixedVersion: '3.6.13-2ubuntu1.7'
}
@ -205,26 +267,31 @@ const mockCVEList = {
PackageList: [
{
Name: 'libncurses6',
PackagePath: 'Not Specified',
InstalledVersion: '6.2-0ubuntu2',
FixedVersion: 'Not Specified'
},
{
Name: 'libncursesw6',
PackagePath: 'Not Specified',
InstalledVersion: '6.2-0ubuntu2',
FixedVersion: 'Not Specified'
},
{
Name: 'libtinfo6',
PackagePath: 'Not Specified',
InstalledVersion: '6.2-0ubuntu2',
FixedVersion: 'Not Specified'
},
{
Name: 'ncurses-base',
PackagePath: 'Not Specified',
InstalledVersion: '6.2-0ubuntu2',
FixedVersion: 'Not Specified'
},
{
Name: 'ncurses-bin',
PackagePath: 'Not Specified',
InstalledVersion: '6.2-0ubuntu2',
FixedVersion: 'Not Specified'
}
@ -239,6 +306,7 @@ const mockCVEList = {
PackageList: [
{
Name: 'libpcre2-8-0',
PackagePath: 'Not Specified',
InstalledVersion: '10.34-7',
FixedVersion: 'Not Specified'
}
@ -253,26 +321,31 @@ const mockCVEList = {
PackageList: [
{
Name: 'libncurses6',
PackagePath: 'Not Specified',
InstalledVersion: '6.2-0ubuntu2',
FixedVersion: 'Not Specified'
},
{
Name: 'libncursesw6',
PackagePath: 'Not Specified',
InstalledVersion: '6.2-0ubuntu2',
FixedVersion: 'Not Specified'
},
{
Name: 'libtinfo6',
PackagePath: 'Not Specified',
InstalledVersion: '6.2-0ubuntu2',
FixedVersion: 'Not Specified'
},
{
Name: 'ncurses-base',
PackagePath: 'Not Specified',
InstalledVersion: '6.2-0ubuntu2',
FixedVersion: 'Not Specified'
},
{
Name: 'ncurses-bin',
PackagePath: 'Not Specified',
InstalledVersion: '6.2-0ubuntu2',
FixedVersion: 'Not Specified'
}
@ -287,6 +360,7 @@ const mockCVEList = {
PackageList: [
{
Name: 'coreutils',
PackagePath: 'Not Specified',
InstalledVersion: '8.30-3ubuntu2',
FixedVersion: 'Not Specified'
}
@ -301,46 +375,55 @@ const mockCVEList = {
PackageList: [
{
Name: 'libasn1-8-heimdal',
PackagePath: 'Not Specified',
InstalledVersion: '7.7.0+dfsg-1ubuntu1',
FixedVersion: 'Not Specified'
},
{
Name: 'libgssapi3-heimdal',
PackagePath: 'Not Specified',
InstalledVersion: '7.7.0+dfsg-1ubuntu1',
FixedVersion: 'Not Specified'
},
{
Name: 'libhcrypto4-heimdal',
PackagePath: 'Not Specified',
InstalledVersion: '7.7.0+dfsg-1ubuntu1',
FixedVersion: 'Not Specified'
},
{
Name: 'libheimbase1-heimdal',
PackagePath: 'Not Specified',
InstalledVersion: '7.7.0+dfsg-1ubuntu1',
FixedVersion: 'Not Specified'
},
{
Name: 'libheimntlm0-heimdal',
PackagePath: 'Not Specified',
InstalledVersion: '7.7.0+dfsg-1ubuntu1',
FixedVersion: 'Not Specified'
},
{
Name: 'libhx509-5-heimdal',
PackagePath: 'Not Specified',
InstalledVersion: '7.7.0+dfsg-1ubuntu1',
FixedVersion: 'Not Specified'
},
{
Name: 'libkrb5-26-heimdal',
PackagePath: 'Not Specified',
InstalledVersion: '7.7.0+dfsg-1ubuntu1',
FixedVersion: 'Not Specified'
},
{
Name: 'libroken18-heimdal',
PackagePath: 'Not Specified',
InstalledVersion: '7.7.0+dfsg-1ubuntu1',
FixedVersion: 'Not Specified'
},
{
Name: 'libwind0-heimdal',
PackagePath: 'Not Specified',
InstalledVersion: '7.7.0+dfsg-1ubuntu1',
FixedVersion: 'Not Specified'
}
@ -355,11 +438,13 @@ const mockCVEList = {
PackageList: [
{
Name: 'libc-bin',
PackagePath: 'Not Specified',
InstalledVersion: '2.31-0ubuntu9.9',
FixedVersion: 'Not Specified'
},
{
Name: 'libc6',
PackagePath: 'Not Specified',
InstalledVersion: '2.31-0ubuntu9.9',
FixedVersion: 'Not Specified'
}
@ -373,6 +458,7 @@ const mockCVEList = {
PackageList: [
{
Name: 'libcurl4',
PackagePath: 'Not Specified',
InstalledVersion: '7.68.0-1ubuntu2.12',
FixedVersion: '7.68.0-1ubuntu2.13'
}
@ -388,26 +474,31 @@ const mockCVEList = {
PackageList: [
{
Name: 'krb5-locales',
PackagePath: 'Not Specified',
InstalledVersion: '1.17-6ubuntu4.1',
FixedVersion: 'Not Specified'
},
{
Name: 'libgssapi-krb5-2',
PackagePath: 'Not Specified',
InstalledVersion: '1.17-6ubuntu4.1',
FixedVersion: 'Not Specified'
},
{
Name: 'libk5crypto3',
PackagePath: 'Not Specified',
InstalledVersion: '1.17-6ubuntu4.1',
FixedVersion: 'Not Specified'
},
{
Name: 'libkrb5-3',
PackagePath: 'Not Specified',
InstalledVersion: '1.17-6ubuntu4.1',
FixedVersion: 'Not Specified'
},
{
Name: 'libkrb5support0',
PackagePath: 'Not Specified',
InstalledVersion: '1.17-6ubuntu4.1',
FixedVersion: 'Not Specified'
}
@ -422,6 +513,7 @@ const mockCVEList = {
PackageList: [
{
Name: 'libsqlite3-0',
PackagePath: 'Not Specified',
InstalledVersion: '3.31.1-4ubuntu0.3',
FixedVersion: '3.31.1-4ubuntu0.4'
}
@ -437,6 +529,7 @@ const mockCVEList = {
PackageList: [
{
Name: 'zlib1g',
PackagePath: 'Not Specified',
InstalledVersion: '1:1.2.11.dfsg-2ubuntu1.3',
FixedVersion: 'Not Specified'
}
@ -450,10 +543,52 @@ const mockCVEListFiltered = {
CVEListForImage: {
Tag: '',
Page: { ItemCount: 20, TotalCount: 20 },
Summary: {
Count: 5,
UnknownCount: 1,
LowCount: 1,
MediumCount: 1,
HighCount: 1,
CriticalCount: 1
},
CVEList: mockCVEList.CVEListForImage.CVEList.filter((e) => e.Id.includes('2022'))
}
};
const mockCVEListFilteredBySeverity = (severity) => {
return {
CVEListForImage: {
Tag: '',
Page: { ItemCount: 20, TotalCount: 20 },
Summary: {
Count: 5,
UnknownCount: 1,
LowCount: 1,
MediumCount: 1,
HighCount: 1,
CriticalCount: 1
},
CVEList: mockCVEList.CVEListForImage.CVEList.filter((e) => e.Severity.includes(severity))
}
};
};
const mockCVEListFilteredExclude = {
CVEListForImage: {
Tag: '',
Page: { ItemCount: 20, TotalCount: 20 },
Summary: {
Count: 5,
UnknownCount: 1,
LowCount: 1,
MediumCount: 1,
HighCount: 1,
CriticalCount: 1
},
CVEList: mockCVEList.CVEListForImage.CVEList.filter((e) => !e.Id.includes('2022'))
}
};
const mockCVEFixed = {
pageOne: {
ImageListWithCVEFixed: {
@ -508,17 +643,76 @@ describe('Vulnerabilties page', () => {
render(<StateVulnerabilitiesWrapper />);
await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1));
await waitFor(() => expect(screen.getAllByText('Total 5')).toHaveLength(1));
await waitFor(() => expect(screen.getAllByText(/fixed in/i)).toHaveLength(20));
await waitFor(() => expect(screen.getAllByText(/CVE/)).toHaveLength(20));
});
it('renders the vulnerabilities by severity', async () => {
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } });
render(<StateVulnerabilitiesWrapper />);
await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1));
await waitFor(() => expect(screen.getAllByText('Total 5')).toHaveLength(1));
await waitFor(() => expect(screen.getAllByText(/CVE-/)).toHaveLength(20));
expect(screen.getByLabelText('Medium')).toBeInTheDocument();
const mediumSeverity = await screen.getByLabelText('Medium');
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEListFilteredBySeverity('MEDIUM') } });
fireEvent.click(mediumSeverity);
await waitFor(() => expect(screen.getAllByText(/CVE-/)).toHaveLength(6));
expect(screen.getByLabelText('High')).toBeInTheDocument();
const highSeverity = await screen.getByLabelText('High');
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEListFilteredBySeverity('HIGH') } });
fireEvent.click(highSeverity);
await waitFor(() => expect(screen.getAllByText(/CVE-/)).toHaveLength(1));
expect(screen.getByLabelText('Critical')).toBeInTheDocument();
const criticalSeverity = await screen.getByLabelText('Critical');
jest
.spyOn(api, 'get')
.mockResolvedValue({ status: 200, data: { data: mockCVEListFilteredBySeverity('CRITICAL') } });
fireEvent.click(criticalSeverity);
await waitFor(() => expect(screen.getAllByText(/CVE-/)).toHaveLength(1));
expect(screen.getByLabelText('Low')).toBeInTheDocument();
const lowSeverity = await screen.getByLabelText('Low');
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEListFilteredBySeverity('LOW') } });
fireEvent.click(lowSeverity);
await waitFor(() => expect(screen.getAllByText(/CVE-/)).toHaveLength(10));
expect(screen.getByLabelText('Unknown')).toBeInTheDocument();
const unknownSeverity = await screen.getByLabelText('Unknown');
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEListFilteredBySeverity('UNKNOWN') } });
fireEvent.click(unknownSeverity);
await waitFor(() => expect(screen.getAllByText(/CVE-/)).toHaveLength(1));
expect(screen.getByText('Total 5')).toBeInTheDocument();
const totalSeverity = await screen.getByText('Total 5');
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEListFilteredBySeverity('') } });
fireEvent.click(totalSeverity);
await waitFor(() => expect(screen.getAllByText(/CVE-/)).toHaveLength(20));
});
it('sends filtered query if user types in the search bar', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEList } });
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } });
render(<StateVulnerabilitiesWrapper />);
await waitFor(() => expect(screen.getAllByText(/CVE/)).toHaveLength(20));
const cveSearchInput = screen.getByPlaceholderText(/search/i);
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEListFiltered } });
await userEvent.type(cveSearchInput, '2022');
expect(cveSearchInput).toHaveValue('2022');
await waitFor(() => expect(screen.queryAllByText(/2022/i)).toHaveLength(7));
await waitFor(() => expect(screen.queryAllByText(/2021/i)).toHaveLength(1));
});
it('should have a collapsable search bar', async () => {
jest
.spyOn(api, 'get')
.mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } })
.mockResolvedValue({ status: 200, data: { data: mockCVEListFilteredExclude } });
render(<StateVulnerabilitiesWrapper />);
const cveSearchInput = screen.getByPlaceholderText(/search/i);
jest.spyOn(api, 'get').mockRejectedValueOnce({ status: 200, data: { data: mockCVEListFiltered } });
await userEvent.type(cveSearchInput, '2022');
expect((await screen.queryAllByText(/2023/i).length) === 0);
expect((await screen.findAllByText(/2022/i)).length === 6);
const expandSearch = cveSearchInput.parentElement.parentElement.parentElement.parentElement.childNodes[0];
await fireEvent.click(expandSearch);
await waitFor(() => expect(screen.getAllByPlaceholderText('Exclude')).toHaveLength(1));
const excludeInput = screen.getByPlaceholderText('Exclude');
await userEvent.type(excludeInput, '2022');
expect(excludeInput).toHaveValue('2022');
await waitFor(() => expect(screen.queryAllByText(/2022/i)).toHaveLength(0));
await waitFor(() => expect(screen.queryAllByText(/2021/i)).toHaveLength(6));
});
it('renders no vulnerabilities if there are not any', async () => {
@ -530,19 +724,18 @@ describe('Vulnerabilties page', () => {
await waitFor(() => expect(screen.getAllByText('No Vulnerabilities')).toHaveLength(1));
});
it('should open and close description dropdown for vulnerabilities', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEList } });
it('should show description for vulnerabilities', async () => {
jest
.spyOn(api, 'get')
.mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } })
.mockResolvedValue({ status: 200, data: { data: mockCVEFixed.pageOne } });
render(<StateVulnerabilitiesWrapper />);
await waitFor(() => expect(screen.getAllByText(/description/i)).toHaveLength(20));
const openText = screen.getAllByText(/description/i);
await fireEvent.click(openText[0]);
const expandListBtn = await screen.findAllByTestId('ViewAgendaIcon');
fireEvent.click(expandListBtn[0]);
await waitFor(() => expect(screen.getAllByText(/Description/)).toHaveLength(20));
await waitFor(() =>
expect(screen.getAllByText(/CPAN 2.28 allows Signature Verification Bypass./i)).toHaveLength(1)
);
await fireEvent.click(openText[0]);
await waitFor(() =>
expect(screen.queryByText(/CPAN 2.28 allows Signature Verification Bypass./i)).not.toBeInTheDocument()
);
});
it("should log an error when data can't be fetched", async () => {
@ -560,55 +753,112 @@ describe('Vulnerabilties page', () => {
.mockResolvedValueOnce({ status: 200, data: { data: mockCVEFixed.pageTwo } });
render(<StateVulnerabilitiesWrapper />);
await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1));
await fireEvent.click(screen.getAllByText(/fixed in/i)[0]);
const expandListBtn = await screen.findAllByTestId('KeyboardArrowRightIcon');
fireEvent.click(expandListBtn[1]);
await waitFor(() => expect(screen.getByText('1.0.16')).toBeInTheDocument());
const loadMoreBtn = screen.getByText(/load more/i);
expect(loadMoreBtn).toBeInTheDocument();
await waitFor(() => expect(screen.getAllByText(/Load more/).length).toBe(1));
const loadMoreBtn = screen.getAllByText(/Load more/)[0];
await fireEvent.click(loadMoreBtn);
await waitFor(() => expect(loadMoreBtn).not.toBeInTheDocument());
expect(await screen.findByText('latest')).toBeInTheDocument();
});
it('should show the list of vulnerable packages for the CVEs', async () => {
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: simpleMockCVEList } });
render(<StateVulnerabilitiesWrapper />);
const expandListBtn = await screen.findByTestId('expand-list-view-toggle');
fireEvent.click(expandListBtn);
const packageLists = await screen.findAllByTestId('cve-package-list');
expect(packageLists.length).toEqual(2); // Data set has 2 CVEs, so 2 package lists
const expectedData = [
{
Name: 'perl-base',
PackagePath: 'Not Specified',
InstalledVersion: '5.30.0-9ubuntu0.2',
FixedVersion: 'Not Specified'
},
{
Name: 'org.springframework:spring-web',
PackagePath: 'usr/local/tomcat/webapps/spring4shell.war/WEB-INF/lib/spring-web-5.3.15.jar',
InstalledVersion: '5.3.15',
FixedVersion: '6.0.0'
}
];
for (let index = 0; index < 2; index++) {
const expectedPackageData = expectedData[index];
const container = packageLists[index];
const pkgName = await within(container).findAllByTestId('cve-info-pkg-name');
expect(pkgName).toHaveLength(1);
expect(pkgName[0]).toHaveTextContent(expectedPackageData.Name);
const pkgPath = await within(container).findAllByTestId('cve-info-pkg-path');
expect(pkgPath).toHaveLength(1);
expect(pkgPath[0]).toHaveTextContent(expectedPackageData.PackagePath);
const pkgInstalledVer = await within(container).findAllByTestId('cve-info-pkg-install-ver');
expect(pkgInstalledVer).toHaveLength(1);
expect(pkgInstalledVer[0]).toHaveTextContent(expectedPackageData.InstalledVersion);
const pkgFixedVer = await within(container).findAllByTestId('cve-info-pkg-fixed-ver');
expect(pkgFixedVer).toHaveLength(1);
expect(pkgFixedVer[0]).toHaveTextContent(expectedPackageData.FixedVersion);
}
});
it('should allow export of vulnerabilities list', async () => {
const xlsxMock = jest.createMockFromModule('xlsx');
xlsxMock.writeFile = jest.fn();
jest
.spyOn(api, 'get')
.mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } })
.mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } });
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEList } });
render(<StateVulnerabilitiesWrapper />);
await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1));
const downloadBtn = await screen.findAllByTestId('DownloadIcon');
fireEvent.click(downloadBtn[0]);
await fireEvent.click(downloadBtn[0]);
expect(await screen.findByTestId('export-csv-menuItem')).toBeInTheDocument();
expect(await screen.findByTestId('export-excel-menuItem')).toBeInTheDocument();
const exportAsCSVBtn = screen.getByText(/csv/i);
expect(exportAsCSVBtn).toBeInTheDocument();
global.URL.createObjectURL = jest.fn();
await fireEvent.click(exportAsCSVBtn);
expect(await screen.findByTestId('export-csv-menuItem')).not.toBeInTheDocument();
fireEvent.click(downloadBtn[0]);
await waitFor(() => expect(screen.queryByTestId('export-csv-menuItem')).not.toBeInTheDocument());
await fireEvent.click(downloadBtn[0]);
const exportAsExcelBtn = screen.getByText(/xlsx/i);
expect(exportAsExcelBtn).toBeInTheDocument();
await fireEvent.click(exportAsExcelBtn);
expect(await screen.findByTestId('export-excel-menuItem')).not.toBeInTheDocument();
await userEvent.click(exportAsExcelBtn);
expect(await screen.queryByTestId('export-excel-menuItem')).not.toBeInTheDocument();
});
it('should expand/collapse the list of CVEs', async () => {
it("should log an error when data can't be fetched for downloading", async () => {
const xlsxMock = jest.createMockFromModule('xlsx');
xlsxMock.writeFile = jest.fn();
jest
.spyOn(api, 'get')
.mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } })
.mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } });
.mockRejectedValue({ status: 500, data: {} });
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
render(<StateVulnerabilitiesWrapper />);
await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1));
const downloadBtn = await screen.findAllByTestId('DownloadIcon');
fireEvent.click(downloadBtn[0]);
expect(await screen.findByTestId('export-csv-menuItem')).toBeInTheDocument();
expect(await screen.findByTestId('export-excel-menuItem')).toBeInTheDocument();
await waitFor(() => expect(error).toBeCalledTimes(1));
});
it('should expand/collapse the list of CVEs', async () => {
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } });
render(<StateVulnerabilitiesWrapper />);
await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1));
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEFixed.pageOne } });
const expandListBtn = await screen.findAllByTestId('ViewAgendaIcon');
await fireEvent.click(expandListBtn[0]);
await waitFor(() => expect(screen.getAllByText('Fixed in')).toHaveLength(20));
const collapseListBtn = await screen.findAllByTestId('ViewHeadlineIcon');
fireEvent.click(collapseListBtn[0]);
expect(await screen.findByText('Fixed in')).not.toBeVisible();
const expandListBtn = await screen.findAllByTestId('ViewAgendaIcon');
fireEvent.click(expandListBtn[0]);
await waitFor(() => expect(screen.getAllByText('Fixed in')).toHaveLength(20));
await fireEvent.click(collapseListBtn[0]);
await waitFor(() => expect(screen.queryByText('Fixed in')).not.toBeInTheDocument());
});
it('should handle fixed CVE query errors', async () => {
@ -619,7 +869,8 @@ describe('Vulnerabilties page', () => {
render(<StateVulnerabilitiesWrapper />);
await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1));
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
await fireEvent.click(screen.getAllByText(/fixed in/i)[0]);
const expandListBtn = await screen.findAllByTestId('KeyboardArrowRightIcon');
fireEvent.click(expandListBtn[1]);
await waitFor(() => expect(screen.getByText(/not fixed/i)).toBeInTheDocument());
await waitFor(() => expect(error).toBeCalledTimes(1));
});

View File

@ -67,11 +67,14 @@ const api = {
return axios.put(urli, payload, config);
},
delete(urli, abortSignal, cfg) {
delete(urli, params, abortSignal, cfg) {
let config = isEmpty(cfg) ? this.getRequestCfg() : cfg;
if (!isEmpty(abortSignal) && isEmpty(config.signal)) {
config = { ...config, signal: abortSignal };
}
if (!isEmpty(params)) {
config = { ...config, params };
}
return axios.delete(urli, config);
}
};
@ -81,6 +84,7 @@ const endpoints = {
authConfig: `/v2/_zot/ext/mgmt`,
openidAuth: `/zot/auth/login`,
logout: `/zot/auth/logout`,
apiKeys: '/zot/auth/apikey',
deleteImage: (name, tag) => `/v2/${name}/manifests/${tag}`,
repoList: ({ pageNumber = 1, pageSize = 15 } = {}) =>
`/v2/_zot/ext/search?query={RepoListWithNewestImage(requestedPage: {limit:${pageSize} offset:${
@ -89,18 +93,30 @@ const endpoints = {
detailedRepoInfo: (name) =>
`/v2/_zot/ext/search?query={ExpandedRepoInfo(repo:"${name}"){Images {Manifests {Digest Platform {Os Arch} Size} Vulnerabilities {MaxSeverity Count} Tag LastUpdated Vendor IsDeletable } Summary {Name LastUpdated Size Platforms {Os Arch} Vendors IsStarred IsBookmarked NewestImage {RepoName IsSigned SignatureInfo { Tool IsTrusted Author } Vulnerabilities {MaxSeverity Count} Manifests {Digest} Tag Vendor Title Documentation DownloadCount Source Description Licenses}}}}`,
detailedImageInfo: (name, tag) =>
`/v2/_zot/ext/search?query={Image(image: "${name}:${tag}"){RepoName IsSigned SignatureInfo { Tool IsTrusted Author } Vulnerabilities {MaxSeverity Count} Referrers {MediaType ArtifactType Size Digest Annotations{Key Value}} Tag Manifests {History {Layer {Size Digest} HistoryDescription {CreatedBy EmptyLayer}} Digest ConfigDigest LastUpdated Size Platform {Os Arch}} Vendor Licenses }}`,
vulnerabilitiesForRepo: (name, { pageNumber = 1, pageSize = 15 }, searchTerm = '') => {
`/v2/_zot/ext/search?query={Image(image: "${name}:${tag}"){RepoName IsSigned SignatureInfo { Tool IsTrusted Author } Vulnerabilities {MaxSeverity Count} Referrers {MediaType ArtifactType Size Digest Annotations{Key Value}} Tag Manifests {History {Layer {Size Digest} HistoryDescription {CreatedBy EmptyLayer}} Digest ConfigDigest LastUpdated Size Platform {Os Arch} Referrers {MediaType ArtifactType Size Digest Annotations{Key Value}}} Vendor Licenses }}`,
vulnerabilitiesForRepo: (
name,
{ pageNumber = 1, pageSize = 15 },
searchTerm = '',
excludedTerm = '',
severity = ''
) => {
let query = `/v2/_zot/ext/search?query={CVEListForImage(image: "${name}", requestedPage: {limit:${pageSize} offset:${
(pageNumber - 1) * pageSize
}}`;
if (!isEmpty(searchTerm)) {
query += `, searchedCVE: "${searchTerm}"`;
}
return `${query}){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity PackageList {Name InstalledVersion FixedVersion}} Summary {Count UnknownCount LowCount MediumCount HighCount CriticalCount}}}`;
if (!isEmpty(excludedTerm)) {
query += `, excludedCVE: "${excludedTerm}"`;
}
if (!isEmpty(severity)) {
query += `, severity: "${severity}"`;
}
return `${query}){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity Reference PackageList {Name PackagePath InstalledVersion FixedVersion}} Summary {Count UnknownCount LowCount MediumCount HighCount CriticalCount}}}`;
},
allVulnerabilitiesForRepo: (name) =>
`/v2/_zot/ext/search?query={CVEListForImage(image: "${name}"){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity Reference PackageList {Name InstalledVersion FixedVersion}}}}`,
`/v2/_zot/ext/search?query={CVEListForImage(image: "${name}"){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity Reference PackageList {Name PackagePath InstalledVersion FixedVersion}}}}`,
imageListWithCVEFixed: (cveId, repoName, { pageNumber = 1, pageSize = 3 }, filter = {}) => {
let filterParam = '';
if (filter.Os || filter.Arch) {

View File

@ -15,7 +15,7 @@ import makeStyles from '@mui/styles/makeStyles';
import { api, endpoints } from '../../api';
import { host } from '../../host';
import { mapToRepo } from 'utilities/objectModels.js';
import { useSearchParams } from 'react-router-dom';
import { useSearchParams } from 'react-router';
import FilterCard from '../Shared/FilterCard.jsx';
import { isEmpty, isNil } from 'lodash';
import filterConstants from 'utilities/filterConstants.js';
@ -221,7 +221,6 @@ function Explore({ searchInputValue }) {
description={item.description}
downloads={item.downloads}
stars={item.stars}
isSigned={item.isSigned}
signatureInfo={item.signatureInfo}
isBookmarked={item.isBookmarked}
isStarred={item.isStarred}

View File

@ -1,5 +1,5 @@
// react global
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { Link, useLocation, useNavigate } from 'react-router';
// components
import { Typography, Breadcrumbs } from '@mui/material';

View File

@ -1,6 +1,6 @@
// react global
import React, { useState, useEffect } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Link, useLocation } from 'react-router';
import { isAuthenticated, isAuthenticationEnabled, logoutUser } from '../../utilities/authUtilities';

View File

@ -6,7 +6,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { api, endpoints } from 'api';
import { host } from 'host';
import { mapToImage, mapToRepo } from 'utilities/objectModels';
import { createSearchParams, useNavigate, useSearchParams } from 'react-router-dom';
import { createSearchParams, useNavigate, useSearchParams } from 'react-router';
import { debounce, isEmpty } from 'lodash';
import { useCombobox } from 'downshift';
import { HEADER_SEARCH_PAGE_SIZE } from 'utilities/paginationConstants';
@ -132,8 +132,14 @@ function SearchSuggestion({ setSearchCurrentValue = () => {} }) {
const handleSearch = (event) => {
const { key, type } = event;
const name = event.target.value;
if (key === 'Enter' || type === 'click') {
navigate({ pathname: `/explore`, search: createSearchParams({ search: inputValue || '' }).toString() });
if (name?.includes(':')) {
const splitName = name.split(':');
navigate(`/image/${encodeURIComponent(splitName[0])}/tag/${splitName[1]}`);
} else {
navigate({ pathname: `/explore`, search: createSearchParams({ search: inputValue || '' }).toString() });
}
}
};

View File

@ -2,11 +2,17 @@ import React, { useState } from 'react';
import { Menu, MenuItem, IconButton, Avatar, Divider } from '@mui/material';
import { getLoggedInUser, logoutUser } from '../../utilities/authUtilities';
import { getLoggedInUser, logoutUser, isApiKeyEnabled } from '../../utilities/authUtilities';
import { useNavigate } from 'react-router';
function UserAccountMenu() {
const [anchorEl, setAnchorEl] = useState(null);
const openMenu = Boolean(anchorEl);
const navigate = useNavigate();
const apiKeyManagement = () => {
navigate('/user/apikey');
};
const handleUserClick = (event) => {
setAnchorEl(event.currentTarget);
@ -24,6 +30,7 @@ function UserAccountMenu() {
aria-controls={open ? 'account-menu' : undefined}
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
data-testid="user-icon-header-button"
>
<Avatar sx={{ width: 32, height: 32 }} />
</IconButton>
@ -37,6 +44,12 @@ function UserAccountMenu() {
>
<MenuItem onClick={handleUserClose}>{getLoggedInUser()}</MenuItem>
<Divider />
{isApiKeyEnabled() && (
<MenuItem onClick={apiKeyManagement} data-testid="api-keys-menu-item">
API Keys
</MenuItem>
)}
{isApiKeyEnabled() && <Divider data-testid="api-keys-menu-item-divider" />}
<MenuItem onClick={logoutUser}>Log out</MenuItem>
</Menu>
</>

View File

@ -6,7 +6,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import RepoCard from '../Shared/RepoCard';
import { mapToRepo } from 'utilities/objectModels';
import Loading from '../Shared/Loading';
import { useNavigate, createSearchParams } from 'react-router-dom';
import { useNavigate, createSearchParams } from 'react-router';
import { sortByCriteria } from 'utilities/sortCriteria';
import {
HOME_POPULAR_PAGE_SIZE,
@ -261,7 +261,6 @@ function Home() {
description={item.description}
downloads={item.downloads}
stars={item.stars}
isSigned={item.isSigned}
signatureInfo={item.signatureInfo}
isBookmarked={item.isBookmarked}
isStarred={item.isStarred}

View File

@ -1,6 +1,6 @@
// react global
import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
// utility
import { api, endpoints } from '../../api';
@ -20,7 +20,7 @@ import Alert from '@mui/material/Alert';
import CircularProgress from '@mui/material/CircularProgress';
import Loading from '../Shared/Loading';
import { GoogleLoginButton, GithubLoginButton, OIDCLoginButton } from './ThirdPartyLoginComponents';
import { GoogleLoginButton, GithubLoginButton, GitlabLoginButton, OIDCLoginButton } from './ThirdPartyLoginComponents';
// styling
import { makeStyles } from '@mui/styles';
@ -149,8 +149,8 @@ const useStyles = makeStyles(() => ({
export default function SignIn({ isLoggedIn, setIsLoggedIn, wrapperSetLoading = () => {} }) {
const [usernameError, setUsernameError] = useState(null);
const [passwordError, setPasswordError] = useState(null);
const [username, setUsername] = useState(null);
const [password, setPassword] = useState(null);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [requestProcessing, setRequestProcessing] = useState(false);
const [requestError, setRequestError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
@ -228,13 +228,20 @@ export default function SignIn({ isLoggedIn, setIsLoggedIn, wrapperSetLoading =
});
};
const handleClick = (event) => {
event.preventDefault();
if (Object.keys(authMethods).includes('htpasswd')) {
const handleBasicAuthSubmit = () => {
setRequestError(false);
const isUsernameValid = handleUsernameValidation(username);
const isPasswordValid = handlePasswordValidation(password);
if (Object.keys(authMethods).includes('htpasswd') && isUsernameValid && isPasswordValid) {
handleBasicAuth();
}
};
const handleClick = (event) => {
event.preventDefault();
handleBasicAuthSubmit();
};
const handleGuestClick = () => {
setRequestProcessing(false);
setRequestError(false);
@ -251,38 +258,58 @@ export default function SignIn({ isLoggedIn, setIsLoggedIn, wrapperSetLoading =
);
};
const handleUsernameValidation = (username) => {
let isValid = true;
if (username === '') {
setUsernameError('Please enter a username');
isValid = false;
} else {
setUsernameError(null);
}
return isValid;
};
const handlePasswordValidation = (password) => {
let isValid = true;
if (password === '') {
setPasswordError('Please enter a password');
isValid = false;
} else {
setPasswordError(null);
}
return isValid;
};
const handleChange = (event, type) => {
event.preventDefault();
setRequestError(false);
const val = event.target?.value;
const isEmpty = val === '';
switch (type) {
case 'username':
setUsername(val);
if (isEmpty) {
setUsernameError('Please enter a username');
} else {
setUsernameError(null);
}
handleUsernameValidation(val);
break;
case 'password':
setPassword(val);
if (isEmpty) {
setPasswordError('Please enter a password');
} else {
setPasswordError(null);
}
handlePasswordValidation(val);
break;
default:
break;
}
};
const handleLoginInputFieldKeyDown = (event) => {
const keyPressed = event.key;
if (keyPressed === 'Enter') {
handleBasicAuthSubmit();
}
};
const renderThirdPartyLoginMethods = () => {
let isGoogle = isObject(authMethods.openid?.providers?.google);
// let isGitlab = isObject(authMethods.openid?.providers?.gitlab);
let isGitlab = isObject(authMethods.openid?.providers?.gitlab);
let isGithub = isObject(authMethods.openid?.providers?.github);
let isOIDC = isObject(authMethods.openid?.providers?.oidc);
let oidcName = authMethods.openid?.providers?.oidc?.name;
@ -291,7 +318,7 @@ export default function SignIn({ isLoggedIn, setIsLoggedIn, wrapperSetLoading =
<Stack direction="column" spacing="1rem" className={classes.thirdPartyLoginContainer}>
{isGithub && <GithubLoginButton handleClick={handleClickExternalLogin} />}
{isGoogle && <GoogleLoginButton handleClick={handleClickExternalLogin} />}
{/* {isGitlab && <GitlabLoginButton handleClick={handleClickExternalLogin} />} */}
{isGitlab && <GitlabLoginButton handleClick={handleClickExternalLogin} />}
{isOIDC && <OIDCLoginButton handleClick={handleClickExternalLogin} oidcName={oidcName} />}
</Stack>
);
@ -312,7 +339,13 @@ export default function SignIn({ isLoggedIn, setIsLoggedIn, wrapperSetLoading =
Welcome back! Please login.
</Typography>
{renderThirdPartyLoginMethods()}
{Object.keys(authMethods).length > 1 && <Divider className={classes.divider}>or</Divider>}
{Object.keys(authMethods).length > 1 &&
Object.keys(authMethods).includes('openid') &&
Object.keys(authMethods.openid.providers).length > 0 && (
<Divider className={classes.divider} data-testid="openid-divider">
or
</Divider>
)}
{Object.keys(authMethods).includes('htpasswd') && (
<Box component="form" onSubmit={null} noValidate autoComplete="off">
<TextField
@ -328,6 +361,7 @@ export default function SignIn({ isLoggedIn, setIsLoggedIn, wrapperSetLoading =
onInput={(e) => handleChange(e, 'username')}
error={usernameError != null}
helperText={usernameError}
onKeyDown={(e) => handleLoginInputFieldKeyDown(e)}
/>
<TextField
margin="normal"
@ -343,6 +377,7 @@ export default function SignIn({ isLoggedIn, setIsLoggedIn, wrapperSetLoading =
onInput={(e) => handleChange(e, 'password')}
error={passwordError != null}
helperText={passwordError}
onKeyDown={(e) => handleLoginInputFieldKeyDown(e)}
/>
{requestProcessing && <CircularProgress style={{ marginTop: 20 }} color="secondary" />}
{requestError && (
@ -351,7 +386,13 @@ export default function SignIn({ isLoggedIn, setIsLoggedIn, wrapperSetLoading =
</Alert>
)}
<div>
<Button fullWidth variant="contained" className={classes.continueButton} onClick={handleClick}>
<Button
fullWidth
variant="contained"
className={classes.continueButton}
onClick={handleClick}
data-testid="basic-auth-submit-btn"
>
Continue
</Button>
</div>

View File

@ -8,7 +8,10 @@ import { isEmpty, uniq } from 'lodash';
// utility
import { api, endpoints } from '../../api';
import { host } from '../../host';
import { useParams, useNavigate, createSearchParams } from 'react-router-dom';
import { useParams, useNavigate, createSearchParams } from 'react-router';
import { mapToRepoFromRepoInfo } from 'utilities/objectModels';
import { isAuthenticated } from 'utilities/authUtilities';
import filterConstants from 'utilities/filterConstants';
// components
import { Card, CardContent, CardMedia, Chip, Grid, Stack, Tooltip, Typography, IconButton } from '@mui/material';
@ -16,7 +19,11 @@ import BookmarkIcon from '@mui/icons-material/Bookmark';
import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder';
import StarIcon from '@mui/icons-material/Star';
import StarBorderIcon from '@mui/icons-material/StarBorder';
import makeStyles from '@mui/styles/makeStyles';
import Tags from './Tabs/Tags.jsx';
import RepoDetailsMetadata from './RepoDetailsMetadata';
import Loading from '../Shared/Loading';
import { Markdown } from 'utilities/MarkdowntojsxWrapper';
import { VulnerabilityIconCheck, SignatureIconCheck } from 'utilities/vulnerabilityAndSignatureCheck';
// placeholder images
import repocube1 from '../../assets/repocube-1.png';
@ -24,13 +31,7 @@ import repocube2 from '../../assets/repocube-2.png';
import repocube3 from '../../assets/repocube-3.png';
import repocube4 from '../../assets/repocube-4.png';
import Tags from './Tabs/Tags.jsx';
import RepoDetailsMetadata from './RepoDetailsMetadata';
import Loading from '../Shared/Loading';
import { Markdown } from 'utilities/MarkdowntojsxWrapper';
import { VulnerabilityIconCheck, SignatureIconCheck } from 'utilities/vulnerabilityAndSignatureCheck';
import { mapToRepoFromRepoInfo } from 'utilities/objectModels';
import { isAuthenticated } from 'utilities/authUtilities';
import makeStyles from '@mui/styles/makeStyles';
const useStyles = makeStyles((theme) => ({
pageWrapper: {
@ -260,6 +261,28 @@ function RepoDetails() {
return lastDate;
};
const getSignatureChips = () => {
const cosign = repoDetailData.signatureInfo
?.map((s) => s.tool)
.includes(filterConstants.signatureToolConstants.COSIGN)
? repoDetailData.signatureInfo.filter((si) => si.tool == filterConstants.signatureToolConstants.COSIGN)
: null;
const notation = repoDetailData.signatureInfo
?.map((s) => s.tool)
.includes(filterConstants.signatureToolConstants.NOTATION)
? repoDetailData.signatureInfo.filter((si) => si.tool == filterConstants.signatureToolConstants.NOTATION)
: null;
const sigArray = [];
if (cosign) sigArray.push(cosign);
if (notation) sigArray.push(notation);
if (sigArray.length === 0) return <SignatureIconCheck />;
return sigArray.map((sig, index) => (
<div className="hide-on-mobile" key={`${name}sig${index}`}>
<SignatureIconCheck signatureInfo={sig} />
</div>
));
};
return (
<>
{isLoading ? (
@ -288,10 +311,7 @@ function RepoDetails() {
</Stack>
<Stack alignItems="center" sx={{ width: { xs: '100%', md: 'auto' } }} direction="row" spacing={2}>
<VulnerabilityIconCheck vulnerabilitySeverity={repoDetailData?.vulnerabilitySeverity} />
<SignatureIconCheck
isSigned={repoDetailData.isSigned}
signatureInfo={repoDetailData.signatureInfo}
/>
{getSignatureChips()}
</Stack>
<Stack alignItems="center" sx={{ width: { xs: '100%', md: 'auto' } }} direction="row" spacing={1}>
{isAuthenticated() && (
@ -304,13 +324,20 @@ function RepoDetails() {
</IconButton>
)}
{isAuthenticated() && (
<IconButton component="span" onClick={handleBookmarkClick} data-testid="bookmark-button">
{repoDetailData?.isBookmarked ? (
<BookmarkIcon data-testid="bookmarked" />
) : (
<BookmarkBorderIcon data-testid="not-bookmarked" />
)}
</IconButton>
<Stack
alignItems="center"
sx={{ width: { xs: '100%', md: 'auto' } }}
direction="row"
spacing={2}
>
<IconButton component="span" onClick={handleBookmarkClick} data-testid="bookmark-button">
{repoDetailData?.isBookmarked ? (
<BookmarkIcon data-testid="bookmarked" />
) : (
<BookmarkBorderIcon data-testid="not-bookmarked" />
)}
</IconButton>
</Stack>
)}
</Stack>
</Stack>

View File

@ -1,9 +1,11 @@
import React from 'react';
import makeStyles from '@mui/styles/makeStyles';
import React, { useState } from 'react';
import transform from 'utilities/transform';
import { Card, CardContent, Typography, Grid, Divider, Stack, Collapse } from '@mui/material';
import { KeyboardArrowDown, KeyboardArrowRight } from '@mui/icons-material';
import transform from 'utilities/transform';
import { useState } from 'react';
import makeStyles from '@mui/styles/makeStyles';
const useStyles = makeStyles(() => ({
card: {

View File

@ -1,7 +1,7 @@
import { Card, CardActionArea, CardContent, CardMedia, Grid, Stack, Tooltip, Typography } from '@mui/material';
import { makeStyles } from '@mui/styles';
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
// placeholder images
import repocube1 from '../../assets/repocube-1.png';
@ -10,7 +10,7 @@ import repocube3 from '../../assets/repocube-3.png';
import repocube4 from '../../assets/repocube-4.png';
import { isEmpty } from 'lodash';
import { VulnerabilityIconCheck, SignatureIconCheck } from 'utilities/vulnerabilityAndSignatureCheck';
import { VulnerabilityIconCheck } from 'utilities/vulnerabilityAndSignatureCheck';
// temporary utility to get image
const randomIntFromInterval = (min, max) => {
@ -67,7 +67,7 @@ const useStyles = makeStyles(() => ({
function PreviewCard(props) {
const classes = useStyles();
const navigate = useNavigate();
const { name, isSigned, vulnerabilityData, logo } = props;
const { name, vulnerabilityData, logo } = props;
const goToDetails = () => {
navigate(`/image/${encodeURIComponent(name)}`);
@ -108,7 +108,6 @@ function PreviewCard(props) {
</Tooltip>
<Stack direction="row" spacing={0.5} sx={{ marginLeft: 'auto', marginRight: 0 }}>
<VulnerabilityIconCheck {...vulnerabilityData} />
<SignatureIconCheck isSigned={isSigned} />
</Stack>
</Stack>
</Grid>

View File

@ -1,6 +1,6 @@
// react global
import React, { useRef, useMemo, useState } from 'react';
import { useNavigate, createSearchParams } from 'react-router-dom';
import { useNavigate, createSearchParams } from 'react-router';
// utility
import { DateTime } from 'luxon';
@ -32,15 +32,16 @@ import StarIcon from '@mui/icons-material/Star';
import StarBorderIcon from '@mui/icons-material/StarBorder';
import { useTheme } from '@emotion/react';
import { VulnerabilityIconCheck, SignatureIconCheck } from 'utilities/vulnerabilityAndSignatureCheck';
import { Markdown } from 'utilities/MarkdowntojsxWrapper';
import filterConstants from 'utilities/filterConstants';
// placeholder images
import repocube1 from '../../assets/repocube-1.png';
import repocube2 from '../../assets/repocube-2.png';
import repocube3 from '../../assets/repocube-3.png';
import repocube4 from '../../assets/repocube-4.png';
import { VulnerabilityIconCheck, SignatureIconCheck } from 'utilities/vulnerabilityAndSignatureCheck';
import { Markdown } from 'utilities/MarkdowntojsxWrapper';
// temporary utility to get image
const randomIntFromInterval = (min, max) => {
return Math.floor(Math.random() * (max - min + 1) + min);
@ -186,7 +187,6 @@ function RepoCard(props) {
description,
downloads,
stars,
isSigned,
signatureInfo,
lastUpdated,
version,
@ -296,6 +296,24 @@ function RepoCard(props) {
);
};
const getSignatureChips = () => {
const cosign = signatureInfo?.map((s) => s.tool).includes(filterConstants.signatureToolConstants.COSIGN)
? signatureInfo.filter((si) => si.tool == filterConstants.signatureToolConstants.COSIGN)
: null;
const notation = signatureInfo?.map((s) => s.tool).includes(filterConstants.signatureToolConstants.NOTATION)
? signatureInfo.filter((si) => si.tool == filterConstants.signatureToolConstants.NOTATION)
: null;
const sigArray = [];
if (cosign) sigArray.push(cosign);
if (notation) sigArray.push(notation);
if (sigArray.length === 0) return <SignatureIconCheck />;
return sigArray.map((sig, index) => (
<div className="hide-on-mobile" key={`${name}sig${index}`}>
<SignatureIconCheck signatureInfo={sig} />
</div>
));
};
return (
<Card variant="outlined" className={classes.card} data-testid="repo-card">
<CardActionArea
@ -323,12 +341,10 @@ function RepoCard(props) {
{name}
</Typography>
</Tooltip>
<div className="hide-on-mobile">
<div className="hide-on-mobile" style={{ display: 'inline-flex' }}>
<VulnerabilityIconCheck {...vulnerabilityData} className="hide-on-mobile" />
</div>
<div className="hide-on-mobile">
<SignatureIconCheck isSigned={isSigned} signatureInfo={signatureInfo} className="hide-on-mobile" />
</div>
{getSignatureChips()}
</Stack>
<Tooltip title={description || 'Description not available'} placement="top">
<Typography className={classes.description} pt={1} sx={{ fontSize: 12 }} gutterBottom noWrap>

View File

@ -1,19 +1,17 @@
import React from 'react';
import React, { useMemo } from 'react';
import { Typography, Stack } from '@mui/material';
import { isEmpty } from 'lodash';
import { getStrongestSignature, getAllAuthorsOfSignatures } from 'utilities/vulnerabilityAndSignatureCheck';
function SignatureTooltip({ isSigned, signatureInfo }) {
const { tool, isTrusted, author } = !isEmpty(signatureInfo)
? signatureInfo[0]
: { tool: 'Unknown', isTrusted: 'Unknown', author: 'Unknown' };
function SignatureTooltip({ signatureInfo }) {
const strongestSignature = useMemo(() => getStrongestSignature(signatureInfo));
return (
return isEmpty(strongestSignature) ? (
<Typography>Not signed</Typography>
) : (
<Stack direction="column">
<Typography>{isSigned ? 'Verified Signature' : 'Unverified Signature'}</Typography>
<Typography>Tool: {tool}</Typography>
<Typography>Trusted: {!isEmpty(isTrusted) ? isTrusted : 'Unknown'}</Typography>
<Typography>Author: {!isEmpty(author) ? author : 'Unknown'}</Typography>
<Typography>Tool: {strongestSignature?.tool || 'Unknown'}</Typography>
<Typography>Signed-by: {getAllAuthorsOfSignatures(signatureInfo) || 'Unknown'}</Typography>
</Stack>
);
}

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { makeStyles } from '@mui/styles';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { Box, Card, CardContent, Collapse, Grid, Stack, Tooltip, Typography, Divider } from '@mui/material';
import { Markdown } from 'utilities/MarkdowntojsxWrapper';
import transform from 'utilities/transform';
@ -167,7 +167,7 @@ export default function TagCard(props) {
</Grid>
<Grid item xs={6} md={3}>
<Typography variant="body1" color="primary">
{el.platform?.Os}/{el.platform?.Arch}
{el.platform?.Os || '----'}/{el.platform?.Arch || '----'}
</Typography>
</Grid>
<Grid

View File

@ -9,10 +9,11 @@ import { Box, Card, CardContent, Stack, Typography, Divider } from '@mui/materia
import makeStyles from '@mui/styles/makeStyles';
import { host } from '../../host';
import { isEmpty } from 'lodash';
import { Link } from 'react-router-dom';
import { Link } from 'react-router';
import { KeyboardArrowDown, KeyboardArrowRight } from '@mui/icons-material';
import { VulnerabilityChipCheck } from 'utilities/vulnerabilityAndSignatureCheck';
import { CVE_FIXEDIN_PAGE_SIZE } from 'utilities/paginationConstants';
import VulnerabilityPackageSection from './VulnerabilityPackageSection';
const useStyles = makeStyles((theme) => ({
card: {
@ -29,18 +30,46 @@ const useStyles = makeStyles((theme) => ({
marginTop: '2rem',
marginBottom: '2rem'
},
cardCollapsed: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
background: '#FFFFFF',
boxShadow: '0rem 0.3125rem 0.625rem rgba(131, 131, 131, 0.08)',
border: '1px solid #E0E5EB',
borderRadius: '0.75rem',
flex: 'none',
alignSelf: 'stretch',
width: '100%'
},
content: {
textAlign: 'left',
color: '#606060',
padding: '2% 3% 2% 3%',
width: '100%'
},
contentCollapsed: {
textAlign: 'left',
color: '#606060',
padding: '1% 3% 1% 3%',
width: '100%',
'&:last-child': {
paddingBottom: '1%'
}
},
cveId: {
color: theme.palette.primary.main,
fontSize: '1rem',
fontWeight: 400,
textDecoration: 'underline'
},
cveIdCollapsed: {
color: theme.palette.primary.main,
fontSize: '0.75rem',
fontWeight: 500,
textDecoration: 'underline',
flexBasis: '19%'
},
cveSummary: {
color: theme.palette.secondary.dark,
fontSize: '0.75rem',
@ -48,6 +77,13 @@ const useStyles = makeStyles((theme) => ({
textOverflow: 'ellipsis',
marginTop: '0.5rem'
},
cveSummaryCollapsed: {
color: theme.palette.secondary.dark,
fontSize: '0.75rem',
fontWeight: '600',
textOverflow: 'ellipsis',
flexBasis: '82%'
},
link: {
color: '#52637A',
fontSize: '1rem',
@ -72,14 +108,15 @@ const useStyles = makeStyles((theme) => ({
},
vulnerabilityCardDivider: {
margin: '1rem 0'
},
cveInfo: {
marginTop: '2%'
}
}));
function VulnerabilitiyCard(props) {
const classes = useStyles();
const { cve, name, platform, expand } = props;
const [openCVE, setOpenCVE] = useState(expand);
const [openDesc, setOpenDesc] = useState(false);
const [openFixed, setOpenFixed] = useState(false);
const [loadingFixed, setLoadingFixed] = useState(true);
const [fixedInfo, setFixedInfo] = useState([]);
const abortController = useMemo(() => new AbortController(), []);
@ -87,9 +124,10 @@ function VulnerabilitiyCard(props) {
// pagination props
const [pageNumber, setPageNumber] = useState(1);
const [isEndOfList, setIsEndOfList] = useState(false);
const [loadMoreInfo, setLoadMoreInfo] = useState(false);
const getPaginatedResults = () => {
if (!openFixed || isEndOfList) {
if (!openCVE || (!loadMoreInfo && !isEmpty(fixedInfo)) || isEndOfList) {
return;
}
setLoadingFixed(true);
@ -112,11 +150,13 @@ function VulnerabilitiyCard(props) {
);
}
setLoadingFixed(false);
setLoadMoreInfo(false);
})
.catch((e) => {
console.error(e);
setIsEndOfList(true);
setLoadingFixed(false);
setLoadMoreInfo(false);
});
};
@ -125,7 +165,7 @@ function VulnerabilitiyCard(props) {
return () => {
abortController.abort();
};
}, [openFixed, pageNumber]);
}, [openCVE, pageNumber, loadMoreInfo]);
useEffect(() => {
setOpenCVE(expand);
@ -133,6 +173,7 @@ function VulnerabilitiyCard(props) {
const loadMore = () => {
if (loadingFixed || isEndOfList) return;
setLoadMoreInfo(true);
setPageNumber((pageNumber) => pageNumber + 1);
};
@ -172,59 +213,83 @@ function VulnerabilitiyCard(props) {
};
return (
<Card className={classes.card} raised>
<CardContent className={classes.content}>
<Stack direction="row" spacing="1.25rem">
<Card className={openCVE ? classes.card : classes.cardCollapsed} raised>
<CardContent className={openCVE ? classes.content : classes.contentCollapsed}>
<Stack direction="row" spacing={openCVE ? '1.25rem' : '0.5rem'}>
{!openCVE ? (
<KeyboardArrowRight className={classes.dropdownCVE} onClick={() => setOpenCVE(!openCVE)} />
) : (
<KeyboardArrowDown className={classes.dropdownCVE} onClick={() => setOpenCVE(!openCVE)} />
)}
<Typography variant="body1" align="left" className={classes.cveId}>
<Typography variant="body1" align="left" className={openCVE ? classes.cveId : classes.cveIdCollapsed}>
{cve.id}
</Typography>
<VulnerabilityChipCheck vulnerabilitySeverity={cve.severity} />
{openCVE ? (
<VulnerabilityChipCheck vulnerabilitySeverity={cve.severity} />
) : (
<Stack direction="row" spacing="0.5rem" flexBasis="90%">
<div style={{ transform: 'scale(0.8)', flexBasis: '18%', flexShrink: '0' }}>
<VulnerabilityChipCheck vulnerabilitySeverity={cve.severity} />
</div>
<Typography variant="body1" align="left" className={classes.cveSummaryCollapsed}>
{cve.title}
</Typography>
</Stack>
)}
</Stack>
<Collapse in={openCVE} timeout="auto" unmountOnExit>
<Typography variant="body1" align="left" className={classes.cveSummary}>
{cve.title}
</Typography>
<Divider className={classes.vulnerabilityCardDivider} />
<Stack className={classes.dropdown} onClick={() => setOpenFixed(!openFixed)}>
{!openFixed ? (
<KeyboardArrowRight className={classes.dropdownText} />
) : (
<KeyboardArrowDown className={classes.dropdownText} />
)}
<Typography className={classes.dropdownText}>Fixed in</Typography>
<Typography variant="body2" align="left" className={classes.cveInfo}>
External reference
</Typography>
<Typography
variant="body2"
align="left"
sx={{ color: '#0F2139', fontSize: '1rem', textDecoration: 'underline' }}
component={Link}
to={cve.reference}
target="_blank"
rel="noreferrer"
>
{cve.reference}
</Typography>
<Typography variant="body2" align="left" className={classes.cveInfo}>
Packages
</Typography>
<Stack
direction="column"
spacing="0.3rem"
sx={{ width: '100%', padding: '0.5rem 0' }}
data-testid="cve-package-list"
>
{cve.packageList.map((pkg) => (
<VulnerabilityPackageSection key={`${cve.id}-${pkg.packageName}`} cve={pkg} />
))}
</Stack>
<Collapse in={openFixed} timeout="auto" unmountOnExit>
<Box sx={{ width: '100%', padding: '0.5rem 0' }}>
{loadingFixed ? (
'Loading...'
) : (
<Stack direction="row" sx={{ flexWrap: 'wrap' }}>
{renderFixedVer()}
{renderLoadMore()}
</Stack>
)}
</Box>
</Collapse>
<Stack className={classes.dropdown} onClick={() => setOpenDesc(!openDesc)}>
{!openDesc ? (
<KeyboardArrowRight className={classes.dropdownText} />
<Typography variant="body2" align="left" className={classes.cveInfo}>
Fixed in
</Typography>
<Box sx={{ width: '100%', padding: '0.5rem 0' }}>
{loadingFixed ? (
'Loading...'
) : (
<KeyboardArrowDown className={classes.dropdownText} />
<Stack direction="row" sx={{ flexWrap: 'wrap' }}>
{renderFixedVer()}
{renderLoadMore()}
</Stack>
)}
<Typography className={classes.dropdownText}>Description</Typography>
</Stack>
<Collapse in={openDesc} timeout="auto" unmountOnExit>
<Box sx={{ padding: '0.5rem 0' }}>
<Typography variant="body2" align="left" sx={{ color: '#0F2139', fontSize: '1rem' }}>
{cve.description}
</Typography>
</Box>
</Collapse>
</Box>
<Typography variant="body2" align="left" className={classes.cveInfo}>
Description
</Typography>
<Box sx={{ padding: '0.5rem 0' }}>
<Typography variant="body2" align="left" sx={{ color: '#0F2139', fontSize: '1rem' }}>
{cve.description}
</Typography>
</Box>
</Collapse>
</CardContent>
</Card>

View File

@ -18,6 +18,8 @@ const lowBorderColor = '#f0ed94';
const unknownColor = '#f2ffdd';
const unknownBorderColor = '#e9f4d7';
const totalBorderColor = '#e0e5eb';
const fontSize = '0.75rem';
const useStyles = makeStyles((theme) => ({
@ -30,7 +32,11 @@ const useStyles = makeStyles((theme) => ({
fontSize: fontSize,
fontWeight: '600',
borderRadius: '3px',
marginBottom: '0'
marginBottom: '0',
cursor: 'pointer'
},
totalSeverity: {
border: '1px solid ' + totalBorderColor
},
severityList: {
fontSize: fontSize,
@ -63,25 +69,27 @@ const useStyles = makeStyles((theme) => ({
function VulnerabilitiyCountCard(props) {
const classes = useStyles();
const { total, critical, high, medium, low, unknown } = props;
const { total, critical, high, medium, low, unknown, filterBySeverity } = props;
return (
<Stack direction="row" spacing="0.5em">
<div className={[classes.cveCountCard].join(' ')}>Total {total}</div>
<Tooltip title="Total" onClick={() => filterBySeverity('')}>
<div className={[classes.cveCountCard, classes.totalSeverity].join(' ')}>Total {total}</div>
</Tooltip>
<div className={classes.severityList}>
<Tooltip title="Critical">
<Tooltip title="Critical" onClick={() => filterBySeverity('CRITICAL')}>
<div className={[classes.cveCountCard, classes.criticalSeverity].join(' ')}>C {critical}</div>
</Tooltip>
<Tooltip title="High">
<Tooltip title="High" onClick={() => filterBySeverity('HIGH')}>
<div className={[classes.cveCountCard, classes.highSeverity].join(' ')}>H {high}</div>
</Tooltip>
<Tooltip title="Medium">
<Tooltip title="Medium" onClick={() => filterBySeverity('MEDIUM')}>
<div className={[classes.cveCountCard, classes.mediumSeverity].join(' ')}>M {medium}</div>
</Tooltip>
<Tooltip title="Low">
<Tooltip title="Low" onClick={() => filterBySeverity('LOW')}>
<div className={[classes.cveCountCard, classes.lowSeverity].join(' ')}>L {low}</div>
</Tooltip>
<Tooltip title="Unknown">
<Tooltip title="Unknown" onClick={() => filterBySeverity('UNKNOWN')}>
<div className={[classes.cveCountCard, classes.unknownSeverity].join(' ')}>U {unknown}</div>
</Tooltip>
</div>

View File

@ -0,0 +1,69 @@
import React from 'react';
import { Divider, Grid, Stack, Typography } from '@mui/material';
import makeStyles from '@mui/styles/makeStyles';
const useStyles = makeStyles(() => ({
cvePackageCard: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
background: '#FFFFFF',
boxShadow: '0rem 0.3125rem 0.625rem rgba(131, 131, 131, 0.08)',
border: '1px solid #E0E5EB',
borderRadius: '0.75rem',
flex: 'none',
alignSelf: 'stretch',
width: '100%'
},
cveInfo: {
marginTop: '2%'
},
vulnerabilityCardDivider: {
margin: '1rem 1rem'
}
}));
function VulnerabilityPackageSection(props) {
const { cve } = props;
const classes = useStyles();
return (
<Stack
direction="column"
spacing="0.2rem"
sx={{ width: '100%', padding: '0.2rem 0.5rem' }}
data-testid="cve-package-section"
>
<Typography variant="overline" color="primary" data-testid="cve-info-pkg-name" sx={{ fontWeight: 'bold' }}>
{cve.packageName}
</Typography>
<Typography variant="body2" className={classes.cveInfo}>
Package Path
</Typography>
<Typography variant="body1" color="primary" data-testid="cve-info-pkg-path">
{cve.packagePath}
</Typography>
<Grid container>
<Grid item xs={6}>
<Typography variant="body2" className={classes.cveInfo}>
Installed Version
</Typography>
<Typography variant="body1" color="primary" data-testid="cve-info-pkg-install-ver">
{cve.packageInstalledVersion}
</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="body2" className={classes.cveInfo}>
Fixed Version
</Typography>
<Typography variant="body1" color="primary" data-testid="cve-info-pkg-fixed-ver">
{cve.packageFixedVersion}
</Typography>
</Grid>
</Grid>
<Divider className={classes.vulnerabilityCardDivider} />
</Stack>
);
}
export default VulnerabilityPackageSection;

View File

@ -107,7 +107,7 @@ function DependsOn(props) {
repoName={dependence.repoName}
tag={dependence.tag}
vendor={dependence.vendor}
isSigned={dependence.isSigned}
signatureInfo={dependence.signatureInfo}
manifests={dependence.manifests}
key={index}
lastUpdated={dependence.lastUpdated}

View File

@ -107,7 +107,7 @@ function IsDependentOn(props) {
repoName={dependence.repoName}
tag={dependence.tag}
vendor={dependence.vendor}
isSigned={dependence.isSigned}
signatureInfo={dependence.signatureInfo}
manifests={dependence.manifests}
key={index}
lastUpdated={dependence.lastUpdated}

View File

@ -30,6 +30,9 @@ import exportFromJSON from 'export-from-json';
import ViewHeadlineIcon from '@mui/icons-material/ViewHeadline';
import ViewAgendaIcon from '@mui/icons-material/ViewAgenda';
import { KeyboardArrowDown, KeyboardArrowRight } from '@mui/icons-material';
import Collapse from '@mui/material/Collapse';
import VulnerabilitiyCard from '../../Shared/VulnerabilityCard';
import VulnerabilityCountCard from '../../Shared/VulnerabilityCountCard';
@ -122,6 +125,21 @@ const useStyles = makeStyles((theme) => ({
padding: '0.3rem',
display: 'flex',
justifyContent: 'left'
},
dropdownArrowBox: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
},
dropdownText: {
color: '#1479FF',
fontSize: '1.5rem',
fontWeight: '600',
cursor: 'pointer',
textAlign: 'center'
},
test: {
width: '95%'
}
}));
@ -135,8 +153,12 @@ function VulnerabilitiesDetails(props) {
const abortController = useMemo(() => new AbortController(), []);
const { name, tag, digest, platform } = props;
const [openExcludeSearch, setOpenExcludeSearch] = useState(false);
// pagination props
const [cveFilter, setCveFilter] = useState('');
const [cveExcludeFilter, setCveExcludeFilter] = useState('');
const [cveSeverityFilter, setCveSeverityFilter] = useState('');
const [pageNumber, setPageNumber] = useState(1);
const [isEndOfList, setIsEndOfList] = useState(false);
const listBottom = useRef(null);
@ -144,7 +166,7 @@ function VulnerabilitiesDetails(props) {
const [anchorExport, setAnchorExport] = useState(null);
const openExport = Boolean(anchorExport);
const [selectedViewMore, setSelectedViewMore] = useState(true);
const [selectedViewMore, setSelectedViewMore] = useState(false);
const getCVERequestName = () => {
return digest !== '' ? `${name}@${digest}` : `${name}:${tag}`;
@ -156,7 +178,9 @@ function VulnerabilitiesDetails(props) {
`${host()}${endpoints.vulnerabilitiesForRepo(
getCVERequestName(),
{ pageNumber, pageSize: EXPLORE_PAGE_SIZE },
cveFilter
cveFilter,
cveExcludeFilter,
cveSeverityFilter
)}`,
abortController.signal
)
@ -226,7 +250,7 @@ function VulnerabilitiesDetails(props) {
const wb = XLSX.utils.book_new(),
ws = XLSX.utils.json_to_sheet(allCveData);
XLSX.utils.book_append_sheet(wb, ws, name.replaceAll('/', '_') + '_' + tag);
XLSX.utils.book_append_sheet(wb, ws, 'vulnerabilities');
XLSX.writeFile(wb, `${name}:${tag}-vulnerabilities.xlsx`);
@ -255,7 +279,17 @@ function VulnerabilitiesDetails(props) {
setAnchorExport(null);
};
const handleExpandCVESearch = () => {
setOpenExcludeSearch((openExcludeSearch) => !openExcludeSearch);
};
const handleCveExcludeFilterChange = (e) => {
const { value } = e.target;
setCveExcludeFilter(value);
};
const debouncedChangeHandler = useMemo(() => debounce(handleCveFilterChange, 300));
const debouncedExcludeFilterChangeHandler = useMemo(() => debounce(handleCveExcludeFilterChange, 300));
useEffect(() => {
getPaginatedCVEs();
@ -289,12 +323,13 @@ function VulnerabilitiesDetails(props) {
useEffect(() => {
if (isLoading) return;
resetPagination();
}, [cveFilter]);
}, [cveFilter, cveExcludeFilter, cveSeverityFilter]);
useEffect(() => {
return () => {
abortController.abort();
debouncedChangeHandler.cancel();
debouncedExcludeFilterChangeHandler.cancel();
};
}, []);
@ -319,8 +354,6 @@ function VulnerabilitiesDetails(props) {
return;
}
console.log('Test');
return !isEmpty(cveSummary) ? (
<VulnerabilityCountCard
total={cveSummary.Count}
@ -329,6 +362,7 @@ function VulnerabilitiesDetails(props) {
medium={cveSummary.MediumCount}
low={cveSummary.LowCount}
unknown={cveSummary.UnknownCount}
filterBySeverity={setCveSeverityFilter}
/>
) : (
<></>
@ -377,6 +411,7 @@ function VulnerabilitiesDetails(props) {
className={classes.view}
selected={selectedViewMore}
onChange={() => setSelectedViewMore(true)}
data-testid="expand-list-view-toggle"
>
<ViewAgendaIcon />
</ToggleButton>
@ -417,18 +452,41 @@ function VulnerabilitiesDetails(props) {
</Menu>
</Stack>
{renderCVESummary()}
<Stack className={classes.search}>
<InputBase
placeholder={'Search'}
classes={{ root: classes.searchInputBase, input: classes.input }}
onChange={debouncedChangeHandler}
/>
<div className={classes.searchIcon}>
<SearchIcon />
<Stack direction="row">
<div className={classes.dropdownArrowBox} onClick={handleExpandCVESearch}>
{!openExcludeSearch ? (
<KeyboardArrowRight className={classes.dropdownText} />
) : (
<KeyboardArrowDown className={classes.dropdownText} />
)}
</div>
<Stack className={classes.test} direction="column" spacing="0.25em">
<Stack className={classes.search}>
<InputBase
placeholder={'Search'}
classes={{ root: classes.searchInputBase, input: classes.input }}
onChange={debouncedChangeHandler}
/>
<div className={classes.searchIcon}>
<SearchIcon />
</div>
</Stack>
<Collapse in={openExcludeSearch} timeout="auto" unmountOnExit>
<Stack className={classes.search}>
<InputBase
placeholder={'Exclude'}
classes={{ root: classes.searchInputBase, input: classes.input }}
onChange={debouncedExcludeFilterChangeHandler}
/>
</Stack>
</Collapse>
</Stack>
</Stack>
<Stack direction="column" spacing={selectedViewMore ? '1rem' : '0.5rem'}>
{renderCVEs()}
{renderListBottom()}
</Stack>
{renderCVEs()}
{renderListBottom()}
</Stack>
);
}

View File

@ -1,9 +1,13 @@
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useLocation, useNavigate, useParams } from 'react-router';
import React, { useEffect, useMemo, useState, useRef } from 'react';
// utility
import { api, endpoints } from '../../api';
import { host } from '../../host';
import { mapToImage } from '../../utilities/objectModels';
import filterConstants from 'utilities/filterConstants';
import { isEmpty, head, uniqBy } from 'lodash';
// components
import {
Card,
@ -19,23 +23,21 @@ import {
Typography,
InputLabel
} from '@mui/material';
import TagDetailsMetadata from './TagDetailsMetadata';
import VulnerabilitiesDetails from './Tabs/VulnerabilitiesDetails';
import HistoryLayers from './Tabs/HistoryLayers';
import DependsOn from './Tabs/DependsOn';
import IsDependentOn from './Tabs/IsDependentOn';
import Loading from '../Shared/Loading';
import { VulnerabilityIconCheck, SignatureIconCheck } from 'utilities/vulnerabilityAndSignatureCheck';
import ReferredBy from './Tabs/ReferredBy';
import makeStyles from '@mui/styles/makeStyles';
import { host } from '../../host';
// placeholder images
import repocube1 from '../../assets/repocube-1.png';
import repocube2 from '../../assets/repocube-2.png';
import repocube3 from '../../assets/repocube-3.png';
import repocube4 from '../../assets/repocube-4.png';
import TagDetailsMetadata from './TagDetailsMetadata';
import VulnerabilitiesDetails from './Tabs/VulnerabilitiesDetails';
import HistoryLayers from './Tabs/HistoryLayers';
import DependsOn from './Tabs/DependsOn';
import IsDependentOn from './Tabs/IsDependentOn';
import { isEmpty, head } from 'lodash';
import Loading from '../Shared/Loading';
import { VulnerabilityIconCheck, SignatureIconCheck } from 'utilities/vulnerabilityAndSignatureCheck';
import ReferredBy from './Tabs/ReferredBy';
const useStyles = makeStyles((theme) => ({
pageWrapper: {
@ -189,7 +191,7 @@ function TagDetails() {
}, [reponame, tag]);
const getPlatform = () => {
return selectedManifest.platform ? selectedManifest.platform : '--/--';
return selectedManifest?.platform ? selectedManifest.platform : '--/--';
};
const handleTabChange = (event, newValue) => {
@ -204,25 +206,52 @@ function TagDetails() {
const renderTabContent = () => {
switch (selectedTab) {
case 'DependsOn':
return <DependsOn name={imageDetailData.name} digest={selectedManifest.digest} />;
return <DependsOn name={imageDetailData?.name} digest={selectedManifest?.digest} />;
case 'IsDependentOn':
return <IsDependentOn name={imageDetailData.name} digest={selectedManifest.digest} />;
return <IsDependentOn name={imageDetailData?.name} digest={selectedManifest?.digest} />;
case 'Vulnerabilities':
return (
<VulnerabilitiesDetails
name={reponame}
tag={tag}
digest={selectedManifest?.digest}
platform={selectedManifest.platform}
platform={selectedManifest?.platform}
/>
);
case 'ReferredBy':
return <ReferredBy referrers={imageDetailData.referrers} />;
const allReferrers = uniqBy(
[...(selectedManifest?.referrers || []), ...(imageDetailData?.referrers || [])],
'digest'
);
return <ReferredBy referrers={allReferrers} />;
default:
return <HistoryLayers name={imageDetailData.name} history={selectedManifest.history} />;
return <HistoryLayers name={imageDetailData?.name} history={selectedManifest?.history || []} />;
}
};
const getSignatureChips = () => {
const cosign = imageDetailData?.signatureInfo
?.map((s) => s.tool)
?.includes(filterConstants.signatureToolConstants.COSIGN)
? imageDetailData?.signatureInfo?.filter((si) => si.tool == filterConstants.signatureToolConstants.COSIGN)
: null;
const notation = imageDetailData?.signatureInfo
?.map((s) => s.tool)
?.includes(filterConstants.signatureToolConstants.NOTATION)
? imageDetailData?.signatureInfo?.filter((si) => si.tool == filterConstants.signatureToolConstants.NOTATION)
: null;
const sigArray = [];
if (cosign) sigArray.push(cosign);
if (notation) sigArray.push(notation);
if (sigArray.length === 0) return <SignatureIconCheck />;
return sigArray.map((sig, index) => (
<div className="hide-on-mobile" key={`${imageDetailData?.name || ''}sig${index}`}>
<SignatureIconCheck signatureInfo={sig} />
</div>
));
};
return (
<>
{isLoading ? (
@ -255,39 +284,38 @@ function TagDetails() {
</Typography>
</Stack>
<Stack alignItems="center" sx={{ width: { xs: '100%', md: 'auto' } }} direction="row" spacing={1}>
<Stack alignItems="center" sx={{ width: { xs: '100%', md: 'auto' } }} direction="row" spacing={2}>
<VulnerabilityIconCheck
vulnerabilitySeverity={imageDetailData.vulnerabiltySeverity}
count={imageDetailData.vulnerabilityCount}
/>
<SignatureIconCheck
isSigned={imageDetailData.isSigned}
signatureInfo={imageDetailData.signatureInfo}
vulnerabilitySeverity={imageDetailData?.vulnerabiltySeverity}
count={imageDetailData?.vulnerabilityCount}
/>
{getSignatureChips()}
</Stack>
</Stack>
<Stack direction="row" alignItems="center" spacing="1rem">
<FormControl sx={{ m: '1', minWidth: '4.6875rem' }} className={classes.sortForm} size="small">
<InputLabel>OS/Arch</InputLabel>
{!isEmpty(selectedManifest) && (
<Select
label="OS/Arch"
value={selectedManifest}
onChange={handleOSArchChange}
MenuProps={{ disableScrollLock: true }}
>
{imageDetailData.manifests.map((el) => (
<MenuItem key={el.digest} value={el}>
{`${el.platform?.Os}/${el.platform?.Arch}`}
</MenuItem>
))}
</Select>
)}
</FormControl>
<Typography gutterBottom className={classes.digest}>
Digest: {selectedManifest?.digest}
</Typography>
</Stack>
{imageDetailData?.manifests && imageDetailData.manifests.length > 0 && (
<Stack direction="row" alignItems="center" spacing="1rem">
<FormControl sx={{ m: '1', minWidth: '4.6875rem' }} className={classes.sortForm} size="small">
<InputLabel>OS/Arch</InputLabel>
{!isEmpty(selectedManifest) && (
<Select
label="OS/Arch"
value={selectedManifest}
onChange={handleOSArchChange}
MenuProps={{ disableScrollLock: true }}
>
{imageDetailData?.manifests?.map((el) => (
<MenuItem key={el.digest} value={el}>
{`${el.platform?.Os || '----'}/${el.platform?.Arch || '----'}`}
</MenuItem>
))}
</Select>
)}
</FormControl>
<Typography gutterBottom className={classes.digest}>
Digest: {selectedManifest?.digest}
</Typography>
</Stack>
)}
</Grid>
</Grid>
</CardContent>
@ -325,7 +353,12 @@ function TagDetails() {
</Grid>
<Grid item xs={12} md={8}>
<Card className={classes.cardRoot}>
<CardContent className={classes.tabCardContent}>{renderTabContent()}</CardContent>
<CardContent
key={`card_content_manifest_key_${selectedManifest?.digest}`}
className={classes.tabCardContent}
>
{renderTabContent()}
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={4} className={classes.metadata}>

View File

@ -1,12 +1,14 @@
import React from 'react';
import { Card, CardContent, Grid, Typography, Tooltip } from '@mui/material';
import makeStyles from '@mui/styles/makeStyles';
import transform from '../../utilities/transform';
import { DateTime } from 'luxon';
import { Markdown } from 'utilities/MarkdowntojsxWrapper';
import transform from '../../utilities/transform';
import { Card, CardContent, Grid, Typography, Tooltip } from '@mui/material';
import PullCommandButton from 'components/Shared/PullCommandButton';
import makeStyles from '@mui/styles/makeStyles';
const useStyles = makeStyles((theme) => ({
card: {
display: 'flex',

View File

@ -0,0 +1,163 @@
import React, { useState } from 'react';
import { DateTime } from 'luxon';
import { isNil } from 'lodash';
import { Card, CardContent, Typography, Grid, Divider, Stack, Collapse, Button } from '@mui/material';
import { KeyboardArrowDown, KeyboardArrowRight } from '@mui/icons-material';
import ApiKeyRevokeDialog from './ApiKeyRevokeDialog';
import makeStyles from '@mui/styles/makeStyles';
const useStyles = makeStyles(() => ({
card: {
marginBottom: 2,
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FFFFFF',
border: '1px solid #E0E5EB',
borderRadius: '0.75rem',
alignSelf: 'stretch',
flexGrow: 0,
order: 0,
width: '100%'
},
content: {
textAlign: 'left',
color: '#52637A',
width: '100%',
boxSizing: 'border-box',
padding: '1rem',
backgroundColor: '#FFFFFF',
'&:hover': {
backgroundColor: '#FFFFFF'
},
'&:last-child': {
paddingBottom: '1rem'
}
},
label: {
fontSize: '1rem',
fontWeight: '400',
paddingRight: '0.5rem',
paddingBottom: '0.5rem',
paddingTop: '0.5rem',
textAlign: 'left',
width: '100%',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
cursor: 'pointer'
},
expirationDate: {
fontSize: '1rem',
fontWeight: '400',
paddingBottom: '0.5rem',
paddingTop: '0.5rem',
textAlign: 'right'
},
revokeButton: {
display: 'flex',
alignItems: 'center',
justifyContent: 'right'
},
dropdownText: {
color: '#1479FF',
fontSize: '1rem',
fontWeight: '600',
cursor: 'pointer',
textAlign: 'center'
},
dropdownButton: {
color: '#1479FF',
fontSize: '0.8125rem',
fontWeight: '600',
cursor: 'pointer'
},
dropdownContentBox: {
boxSizing: 'border-box',
color: '#52637A',
fontSize: '1rem',
fontWeight: '400',
padding: '0.75rem',
backgroundColor: '#F7F7F7',
borderRadius: '0.9rem',
overflowWrap: 'break-word'
},
keyCardDivider: {
margin: '1rem 0'
}
}));
function ApiKeyCard(props) {
const classes = useStyles();
const { apiKey, onRevoke } = props;
const [openDropdown, setOpenDropdown] = useState(false);
const [apiKeyRevokeOpen, setApiKeyRevokeOpen] = useState(false);
const getExpirationDisplay = () => {
const expDateTime = DateTime.fromISO(apiKey.expirationDate);
return `Expires on ${expDateTime.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY)}`;
};
const handleApiKeyRevokeDialogOpen = () => {
setApiKeyRevokeOpen(true);
};
return (
<Card variant="outlined" className={classes.card}>
<CardContent className={classes.content}>
<Grid container alignItems="center" justifyContent="space-between">
<Grid item xs={6}>
<Typography variant="body1" className={classes.label}>
{apiKey.label}
</Typography>
</Grid>
<Grid item xs={4}>
<Typography variant="body1" className={classes.expirationDate}>
{getExpirationDisplay()}
</Typography>
</Grid>
<Grid item xs={2} className={classes.revokeButton}>
<Button color="error" variant="contained" onClick={handleApiKeyRevokeDialogOpen}>
Revoke
</Button>
</Grid>
{!isNil(apiKey.apiKey) && (
<>
<Grid item xs={12}>
<Divider className={classes.keyCardDivider} />
</Grid>
<Grid item xs={12}>
<Stack direction="row" onClick={() => setOpenDropdown((prevOpenState) => !prevOpenState)}>
{!openDropdown ? (
<KeyboardArrowRight className={classes.dropdownText} />
) : (
<KeyboardArrowDown className={classes.dropdownText} />
)}
<Typography className={classes.dropdownButton}>KEY</Typography>
</Stack>
<Collapse in={openDropdown} timeout="auto" unmountOnExit sx={{ marginTop: '1rem' }}>
<Stack direction="column" spacing="1.2rem">
<Typography variant="body1" align="left" className={classes.dropdownContentBox}>
{apiKey.apiKey}
</Typography>
</Stack>
</Collapse>
</Grid>
</>
)}
</Grid>
<ApiKeyRevokeDialog
open={apiKeyRevokeOpen}
setOpen={setApiKeyRevokeOpen}
apiKey={apiKey}
onConfirm={onRevoke}
/>
</CardContent>
</Card>
);
}
export default ApiKeyCard;

View File

@ -0,0 +1,57 @@
import React from 'react';
import { Dialog, DialogContent, DialogTitle, DialogActions, Button, Typography, Grid } from '@mui/material';
import { makeStyles } from '@mui/styles';
const useStyles = makeStyles(() => ({
gridWrapper: {
paddingTop: '2rem',
paddingBottom: '2rem'
},
apiKeyDisplay: {
boxSizing: 'border-box',
color: '#52637A',
fontSize: '1rem',
fontWeight: '400',
padding: '0.75rem',
backgroundColor: '#F7F7F7',
borderRadius: '0.9rem',
overflowWrap: 'break-word'
}
}));
function ApiKeyConfirmDialog(props) {
const { open, setOpen, apiKey } = props;
const classes = useStyles();
const handleClose = () => {
setOpen(false);
};
return (
<Dialog open={open} onClose={handleClose}>
<DialogTitle>Api Key &quot;{apiKey?.label}&quot; Created</DialogTitle>
<DialogContent className={classes.apiKeyForm}>
<Grid container className={classes.gridWrapper}>
<Grid item xs={12}>
<Typography>Please copy the api key, you will not be able to see it once the page is refreshed</Typography>
</Grid>
<Grid item xs={12}>
<Typography variant="body1" align="center" className={classes.apiKeyDisplay}>
{apiKey?.apiKey}
</Typography>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button variant="outlined" onClick={handleClose}>
Close
</Button>
</DialogActions>
</Dialog>
);
}
export default ApiKeyConfirmDialog;

View File

@ -0,0 +1,172 @@
import React, { useState } from 'react';
import { isNil, isNumber } from 'lodash';
import { DateTime } from 'luxon';
import { api, endpoints } from 'api';
import { host } from 'host';
import {
Dialog,
DialogContent,
TextField,
DialogTitle,
DialogActions,
Button,
FormControl,
InputLabel,
Select,
MenuItem,
Typography,
Grid
} from '@mui/material';
import { DatePicker } from '@mui/x-date-pickers';
import { makeStyles } from '@mui/styles';
const useStyles = makeStyles(() => ({
gridWrapper: {
paddingTop: '2rem',
paddingBottom: '2rem'
},
apiKeyLabel: {
paddingBottom: '1rem'
},
expirationDateContainer: {
width: '100%'
},
expirationDateInput: {
width: '100%'
},
expirationDateDisplay: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}
}));
function ApiKeyDialog(props) {
const { open, setOpen, onConfirm } = props;
const [apiKeyLabel, setApiKeyLabel] = useState();
const [expirationDateOffset, setExpirationDateOffset] = useState(30);
const [selectedExpirationDate, setSelectedExpirationDate] = useState();
const classes = useStyles();
const handleClose = () => {
setOpen(false);
};
const handleSubmit = () => {
api
.post(`${host()}${endpoints.apiKeys}`, {
label: apiKeyLabel,
expirationDate: getExpirationDatetime().toISO()
})
.then((response) => {
if (response.data) {
onConfirm(response.data);
setOpen(false);
}
})
.catch((error) => {
console.error(error);
});
};
const handleLabelChange = (e) => {
const { value } = e.target;
setApiKeyLabel(value);
};
const handleExpirationDateChange = (e) => {
const { value } = e.target;
setExpirationDateOffset(value);
};
const handleDatePickerChange = (newValue) => {
setSelectedExpirationDate(newValue);
};
const getExpirationDatetime = () => {
if (isNumber(expirationDateOffset)) {
return DateTime.now().plus({ days: expirationDateOffset }).endOf('day');
} else if (expirationDateOffset === 'custom') {
return DateTime.fromISO(selectedExpirationDate);
}
return null;
};
const getExpirationDisplay = () => {
const expDateTime = getExpirationDatetime();
return `Expires on ${expDateTime.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY)}`;
};
return (
<Dialog open={open} onClose={handleClose}>
<DialogTitle>Create Api Key</DialogTitle>
<DialogContent className={classes.apiKeyForm}>
<Grid container className={classes.gridWrapper}>
<Grid item container className={classes.apiKeyLabel} xs={12}>
<TextField
autoFocus
required
id="apikeylabel"
label="Label"
fullWidth
variant="outlined"
onChange={handleLabelChange}
/>
</Grid>
<Grid container item xs={12}>
<Grid item xs={5}>
<FormControl className={classes.expirationDateContainer} size="small" required>
<InputLabel disableAnimation>Expiration date</InputLabel>
<Select
labelId="expirationDate"
id="expirationDate"
label="Expiration time"
onChange={handleExpirationDateChange}
value={expirationDateOffset}
className={classes.expirationDateInput}
>
<MenuItem value={7}>7 days</MenuItem>
<MenuItem value={30}>30 days</MenuItem>
<MenuItem value={60}>60 days</MenuItem>
<MenuItem value={90}>90 days</MenuItem>
<MenuItem value="custom">custom</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item className={classes.expirationDateDisplay} xs={7}>
{expirationDateOffset === 'custom' ? (
<DatePicker
valueType="date"
slotProps={{ textField: { size: 'small' } }}
onChange={handleDatePickerChange}
/>
) : (
<Typography>{getExpirationDisplay()}</Typography>
)}
</Grid>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button
variant="contained"
color="success"
onClick={handleSubmit}
disabled={expirationDateOffset === 'custom' && isNil(selectedExpirationDate)}
>
Create
</Button>
<Button variant="outlined" onClick={handleClose}>
Cancel
</Button>
</DialogActions>
</Dialog>
);
}
export default ApiKeyDialog;

View File

@ -0,0 +1,71 @@
import React from 'react';
import { api, endpoints } from 'api';
import { host } from 'host';
import { Dialog, DialogContent, DialogTitle, DialogActions, Button, Typography, Grid } from '@mui/material';
import { makeStyles } from '@mui/styles';
const useStyles = makeStyles(() => ({
gridWrapper: {
paddingTop: '2rem',
paddingBottom: '2rem'
},
apiKeyDisplay: {
boxSizing: 'border-box',
color: '#52637A',
fontSize: '1rem',
fontWeight: '400',
padding: '0.75rem',
backgroundColor: '#F7F7F7',
borderRadius: '0.9rem',
overflowWrap: 'break-word'
}
}));
function ApiKeyRevokeDialog(props) {
const { open, setOpen, apiKey, onConfirm } = props;
const classes = useStyles();
const handleClose = () => {
setOpen(false);
};
const handleSubmit = () => {
api
.delete(`${host()}${endpoints.apiKeys}`, { id: apiKey.uuid })
.then((response) => {
onConfirm(response?.status, apiKey);
setOpen(false);
})
.catch((error) => {
console.error(error);
});
};
return (
<Dialog open={open} onClose={handleClose}>
<DialogTitle>Revoke &quot;{apiKey?.label}&quot; key</DialogTitle>
<DialogContent className={classes.apiKeyForm}>
<Grid container className={classes.gridWrapper}>
<Grid item xs={12}>
<Typography>Are you sure you want to revoke this api key?</Typography>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button variant="contained" color="error" onClick={handleSubmit}>
Revoke
</Button>
<Button variant="outlined" onClick={handleClose}>
Close
</Button>
</DialogActions>
</Dialog>
);
}
export default ApiKeyRevokeDialog;

View File

@ -0,0 +1,146 @@
import React, { useEffect, useMemo, useState } from 'react';
import { isEmpty, isNil } from 'lodash';
import { api, endpoints } from 'api';
import { host } from '../../../host';
import { Grid, Stack, Card, CardContent, Typography, Button } from '@mui/material';
import Loading from '../../Shared/Loading';
import ApiKeyDialog from './ApiKeyDialog';
import ApiKeyConfirmDialog from './ApiKeyConfirmDialog';
import ApiKeyCard from './ApiKeyCard';
import { makeStyles } from '@mui/styles';
const useStyles = makeStyles((theme) => ({
pageWrapper: {
backgroundColor: 'transparent',
height: '100%'
},
header: {
[theme.breakpoints.down('md')]: {
padding: '0'
}
},
cardRoot: {
boxShadow: 'none!important'
},
pageTitle: {
fontWeight: '600',
fontSize: '1.5rem',
color: theme.palette.secondary.main,
textAlign: 'left'
},
apikeysContainer: {
marginTop: '1.5rem',
height: '100%',
[theme.breakpoints.down('md')]: {
padding: '0'
}
},
apikeysContent: {
padding: '1.5rem'
}
}));
function ApiKeys() {
const abortController = useMemo(() => new AbortController(), []);
const [isLoading, setIsLoading] = useState(true);
const [apiKeys, setApiKeys] = useState([]);
const [newApiKey, setNewApiKey] = useState();
const classes = useStyles();
// ApiKey dialog props
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
const [apiKeyConfirmationOpen, setApiKeyConfirmationOpen] = useState(false);
useEffect(() => {
setIsLoading(true);
api
.get(`${host()}${endpoints.apiKeys}`)
.then((response) => {
if (response.data && response.data.apiKeys) {
setApiKeys(response.data.apiKeys);
}
setIsLoading(false);
})
.catch((e) => {
console.error(e);
setIsLoading(false);
});
return () => {
abortController.abort();
};
}, []);
useEffect(() => {
if (!isNil(newApiKey) && !apiKeyConfirmationOpen) {
setApiKeyConfirmationOpen(true);
}
}, [newApiKey]);
const handleApiKeyDialogOpen = () => {
setApiKeyDialogOpen(true);
};
const handleApiKeyCreateConfirm = (apiKey) => {
setNewApiKey(apiKey);
setApiKeys((prevState) => [...prevState, apiKey]);
};
const handleApiKeyRevokeConfirm = (status, apiKey) => {
if (status === 200) setApiKeys((prevState) => prevState.filter((ak) => ak.uuid != apiKey.uuid));
};
const renderApiKeys = () => {
return apiKeys.map((apiKey) => (
<ApiKeyCard key={apiKey.uuid} apiKey={apiKey} onRevoke={handleApiKeyRevokeConfirm} />
));
};
return (
<>
{isLoading ? (
<Loading />
) : (
<Grid container className={classes.pageWrapper}>
<Grid item xs={12} md={12}>
<Card className={classes.cardRoot}>
<CardContent>
<Grid container className={classes.header}>
<Grid item xs={12}>
<Stack direction="row" justifyContent="space-between">
<Typography variant="h4" className={classes.pageTitle}>
Manage your API Keys
</Typography>
<Button variant="contained" color="success" onClick={handleApiKeyDialogOpen}>
Create new API key
</Button>
</Stack>
</Grid>
</Grid>
</CardContent>
</Card>
</Grid>
{!isLoading && !isEmpty(apiKeys) && (
<Grid item xs={12} className={classes.apikeysContainer}>
<Card className={classes.cardRoot}>
<CardContent className={classes.apikeysContent}>
<Stack direction="column" spacing={1}>
{renderApiKeys()}
</Stack>
</CardContent>
</Card>
</Grid>
)}
<ApiKeyDialog open={apiKeyDialogOpen} setOpen={setApiKeyDialogOpen} onConfirm={handleApiKeyCreateConfirm} />
{!isNil(newApiKey) && (
<ApiKeyConfirmDialog open={apiKeyConfirmationOpen} setOpen={setApiKeyConfirmationOpen} apiKey={newApiKey} />
)}
</Grid>
)}
</>
);
}
export default ApiKeys;

View File

@ -1,10 +1,12 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { createTheme, ThemeProvider, StyledEngineProvider, adaptV4Theme } from '@mui/material/styles';
import { LocalizationProvider } from '@mui/x-date-pickers';
import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon';
import { createRoot } from 'react-dom/client';
const theme = createTheme(
adaptV4Theme({
@ -32,15 +34,19 @@ theme.typography.h4 = {
}
};
ReactDOM.render(
const container = document.getElementById('root');
const root = createRoot(container);
root.render(
<React.StrictMode>
<StyledEngineProvider injectFirst>
<ThemeProvider theme={theme}>
<App />
<LocalizationProvider dateAdapter={AdapterLuxon}>
<App />
</LocalizationProvider>
</ThemeProvider>
</StyledEngineProvider>
</React.StrictMode>,
document.getElementById('root')
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function

View File

@ -14,7 +14,6 @@ const useStyles = makeStyles(() => ({
minWidth: '60%'
},
gridWrapper: {
// backgroundColor: "#fff",
border: '0.0625em #f2f2f2 dashed'
},
pageWrapper: {

View File

@ -0,0 +1,57 @@
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router';
import { isEmpty } from 'lodash';
import { getLoggedInUser } from 'utilities/authUtilities.js';
import { Container, Grid, Stack } from '@mui/material';
import Header from '../components/Header/Header.jsx';
import ApiKeys from '../components/User/ApiKeys/ApiKeys.jsx';
import makeStyles from '@mui/styles/makeStyles';
const useStyles = makeStyles(() => ({
container: {
paddingTop: 30,
paddingBottom: 5,
height: '100%',
minWidth: '60%'
},
gridWrapper: {
border: '0.0625rem #f2f2f2 dashed'
},
pageWrapper: {
height: '100%'
},
tile: {
width: '100%',
padding: 5
}
}));
function UserManagementPage() {
const classes = useStyles();
const navigate = useNavigate();
useEffect(() => {
if (isEmpty(getLoggedInUser())) {
navigate('/home');
}
}, []);
return (
<Stack className={classes.pageWrapper} direction="column" data-testid="explore-container">
<Header />
<Container className={classes.container}>
<Grid container className={classes.gridWrapper}>
<Grid item className={classes.tile}>
<ApiKeys />
</Grid>
</Grid>
</Container>
</Stack>
);
}
export default UserManagementPage;

View File

@ -3,3 +3,7 @@
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
import { TextEncoder } from 'node:util';
global.TextEncoder = TextEncoder;

View File

@ -1,5 +1,5 @@
import React from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import { Navigate, Outlet } from 'react-router';
import makeStyles from '@mui/styles/makeStyles';
const useStyles = makeStyles(() => ({

View File

@ -41,10 +41,15 @@ const isAuthenticationEnabled = () => {
return Object.keys(authMethods).length > 0;
};
const isApiKeyEnabled = () => {
const authConfig = JSON.parse(localStorage.getItem('authConfig')) || {};
return authConfig?.apikey;
};
const getLoggedInUser = () => {
const userCookie = getCookie('user');
if (!userCookie) return null;
return userCookie;
};
export { isAuthenticated, isAuthenticationEnabled, getLoggedInUser, logoutUser };
export { isAuthenticated, isAuthenticationEnabled, isApiKeyEnabled, getLoggedInUser, logoutUser };

View File

@ -66,9 +66,24 @@ const archFilters = [
label: 'amd64',
value: 'amd64',
tooltip: '64-bit x86'
},
{
label: 'loong64',
value: 'loong64',
tooltip: '64-bit LoongArch'
},
{
label: 'riscv64',
value: 'riscv64',
tooltip: '64-bit RISC-V'
}
];
const filterConstants = { osFilters, imageFilters, archFilters };
const signatureToolConstants = {
COSIGN: 'cosign',
NOTATION: 'notation'
};
const filterConstants = { osFilters, imageFilters, archFilters, signatureToolConstants };
export default filterConstants;

View File

@ -86,7 +86,8 @@ const mapToManifest = (responseManifest) => {
starCount: responseManifest.StarCount,
layers: responseManifest.Layers,
history: responseManifest.History,
vulnerabilities: responseManifest.Vulnerabilities
vulnerabilities: responseManifest.Vulnerabilities,
referrers: responseManifest.Referrers
};
};
@ -96,7 +97,14 @@ const mapCVEInfo = (cveInfo) => {
id: cve.Id,
severity: cve.Severity,
title: cve.Title,
description: cve.Description
description: cve.Description,
reference: cve.Reference,
packageList: cve.PackageList?.map((pkg) => ({
packageName: pkg.Name,
packagePath: pkg.PackagePath,
packageInstalledVersion: pkg.InstalledVersion,
packageFixedVersion: pkg.FixedVersion
}))
};
});
return cveList;
@ -112,6 +120,7 @@ const mapAllCVEInfo = (cveInfo) => {
description: cve.Description,
reference: cve.Reference,
packageName: packageInfo.Name,
packagePath: packageInfo.PackagePath,
packageInstalledVersion: packageInfo.InstalledVersion,
packageFixedVersion: packageInfo.FixedVersion
};
@ -124,12 +133,12 @@ const mapSignatureInfo = (signatureInfo) => {
return signatureInfo
? {
tool: signatureInfo.Tool,
isTrusted: signatureInfo.IsTrusted?.toString(),
isTrusted: signatureInfo.IsTrusted,
author: signatureInfo.Author
}
: {
tool: 'Unknown',
isTrusted: 'Unknown',
isTrusted: false,
author: 'Unknown'
};
};

View File

@ -1,3 +1,4 @@
import { isEmpty } from 'lodash';
import React from 'react';
import {
NoneVulnerabilityIcon,
@ -12,14 +13,26 @@ import {
CriticalVulnerabilityChip,
UnverifiedSignatureIcon,
VerifiedSignatureIcon,
UnverifiedSignatureChip,
VerifiedSignatureChip,
UnknownVulnerabilityIcon,
UnknownVulnerabilityChip,
FailedScanIcon,
FailedScanChip
FailedScanChip,
NotTrustedSignatureIcon
} from './vulnerabilityAndSignatureComponents';
const getStrongestSignature = (signatureInfo) => {
if (isEmpty(signatureInfo)) return null;
const trusted = signatureInfo.find((si) => si.isTrusted);
if (!isEmpty(trusted)) return trusted;
return signatureInfo[0];
};
const getAllAuthorsOfSignatures = (signatureInfo) => {
if (isEmpty(signatureInfo)) return '';
const signatureAuthors = signatureInfo.filter((si) => si.isTrusted).map((si) => si.author);
return signatureAuthors.join(',');
};
const VulnerabilityIconCheck = ({ vulnerabilitySeverity }) => {
let result;
let vulnerabilityStringTitle = '';
@ -84,20 +97,17 @@ const VulnerabilityChipCheck = ({ vulnerabilitySeverity }) => {
return result;
};
const SignatureIconCheck = ({ isSigned, signatureInfo }) => {
if (isSigned) {
return <VerifiedSignatureIcon signatureInfo={signatureInfo} />;
} else {
return <UnverifiedSignatureIcon signatureInfo={signatureInfo} />;
}
const SignatureIconCheck = ({ signatureInfo }) => {
const strongestSignature = getStrongestSignature(signatureInfo);
if (strongestSignature === null) return <UnverifiedSignatureIcon signatureInfo={signatureInfo} />;
if (strongestSignature.isTrusted) return <VerifiedSignatureIcon signatureInfo={signatureInfo} />;
return <NotTrustedSignatureIcon signatureInfo={signatureInfo} />;
};
const SignatureChipCheck = ({ isSigned }) => {
if (isSigned) {
return <VerifiedSignatureChip />;
} else {
return <UnverifiedSignatureChip />;
}
export {
VulnerabilityIconCheck,
VulnerabilityChipCheck,
SignatureIconCheck,
getStrongestSignature,
getAllAuthorsOfSignatures
};
export { VulnerabilityIconCheck, VulnerabilityChipCheck, SignatureIconCheck, SignatureChipCheck };

View File

@ -1,9 +1,10 @@
import React from 'react';
import { Chip, Tooltip } from '@mui/material';
import { Chip, Tooltip, Badge } from '@mui/material';
import SvgIcon from '@mui/material/SvgIcon';
import { ReactComponent as failedScanBug } from '../assets/failedScan.svg';
import { createSvgIcon } from '@mui/material/utils';
import SignatureTooltip from 'components/Shared/SignatureTooltip';
import filterConstants from 'utilities/filterConstants';
const FilledBugIcon = createSvgIcon(
<path d="M17.0293 5.13093V6.1543H18.3828L21.2414 3.24068L22.2621 4.27812L19.5552 7.03876L19.5879 7.12668C20.1841 8.73695 20.4862 10.4449 20.4793 12.1662C20.4793 12.5064 20.4678 12.8466 20.4448 13.186L20.4397 13.2634H24V14.7334H20.2569L20.2466 14.7932C19.9431 16.4882 19.3517 18.0338 18.5466 19.335L18.4862 19.4335L21.9276 22.9608L20.9052 24L17.6121 20.6239L17.5138 20.7365C16.0259 22.4333 14.0983 23.4514 11.9983 23.4514C9.86724 23.4514 7.91207 22.4016 6.41552 20.6573L6.31552 20.5413L3.08966 23.833L2.06897 22.792L5.45345 19.3403L5.39483 19.2436C4.61897 17.9618 4.04655 16.4478 3.75 14.7932L3.73966 14.7334H0V13.2634H3.55862L3.55345 13.1843C3.53103 12.8502 3.51897 12.509 3.51897 12.1644C3.51202 10.4654 3.80581 8.77905 4.38621 7.18646L4.41897 7.1003L1.64138 4.2535L2.66379 3.21606L5.53103 6.1543H6.96724V5.13093C6.96724 3.77012 7.49729 2.46505 8.4408 1.50281C9.3843 0.540578 10.664 0 11.9983 0C13.3326 0 14.6123 0.540578 15.5558 1.50281C16.4993 2.46505 17.0293 3.77012 17.0293 5.13093Z" />,
@ -14,11 +15,15 @@ const OutlinedBugIcon = createSvgIcon(
'OutlinedBug'
);
const UnverifiedShieldIcon = createSvgIcon(
<path d="M12.4837 2C13.6167 2 19.5627 4.041 20.3487 4.828C21.0047 5.484 20.9947 6.014 20.9487 8.557C20.9307 9.575 20.9057 10.962 20.9057 12.879C20.9057 19.761 13.0357 22.223 12.7007 22.324C12.6297 22.346 12.5567 22.356 12.4837 22.356C12.4107 22.356 12.3377 22.346 12.2667 22.324C11.9317 22.223 4.06165 19.761 4.06165 12.879C4.06165 10.959 4.03665 9.572 4.01865 8.554C4.01044 8.10043 4.00337 7.71095 4.00104 7.37341L4.00073 6.9925C4.00922 5.74112 4.1264 5.32 4.61765 4.828C5.40465 4.041 11.3507 2 12.4837 2ZM12.4837 3.5C11.6357 3.5 6.28465 5.384 5.66765 5.899C5.54931 6.018 5.50535 6.19514 5.49972 6.89808L5.49926 7.16877C5.50045 7.51182 5.50742 7.95335 5.51765 8.526C5.53665 9.552 5.56165 10.947 5.56165 12.879C5.56165 18.08 11.2837 20.389 12.4827 20.814C13.6807 20.387 19.4057 18.065 19.4057 12.879C19.4057 10.949 19.4307 9.555 19.4487 8.529C19.4592 7.95581 19.4663 7.51389 19.4674 7.17033L19.4668 6.89918C19.4605 6.19482 19.4138 6.01519 19.2877 5.889C18.6817 5.384 13.3317 3.5 12.4837 3.5ZM11.1346 9.5372L12.4837 10.887L13.8328 9.5372C14.1258 9.2442 14.5998 9.2442 14.8928 9.5372C15.1858 9.8302 15.1858 10.3042 14.8928 10.5972L13.5437 11.947L14.8926 13.2952C15.1856 13.5882 15.1856 14.0622 14.8926 14.3552C14.7466 14.5022 14.5546 14.5752 14.3626 14.5752C14.1706 14.5752 13.9786 14.5022 13.8326 14.3552L12.4837 13.007L11.1348 14.3552C10.9888 14.5022 10.7968 14.5752 10.6048 14.5752C10.4128 14.5752 10.2208 14.5022 10.0748 14.3552C9.78175 14.0622 9.78175 13.5882 10.0748 13.2952L11.4237 11.947L10.0746 10.5972C9.78155 10.3042 9.78155 9.8302 10.0746 9.5372C10.3676 9.2442 10.8416 9.2442 11.1346 9.5372Z" />,
<path d="M9,0,0,4v6c0,5.55,3.84,10.74,9,12,5.16-1.26,9-6.45,9-12V4Zm7,10a10.47,10.47,0,0,1-7,9.93A10.47,10.47,0,0,1,2,10V5.3L9,2.19,16,5.3ZM7,7.74l1.22,1.4c.32.36.58.7.86,1.06H9.1c.28-.39.56-.72.84-1.07l1.2-1.39h1.68L9.91,10.89l3,3.37H11.14L9.89,12.79c-.33-.38-.62-.74-.92-1.13h0c-.28.39-.58.74-.9,1.13L6.8,14.26H5.09l3-3.33L5.23,7.74Z" />,
'UnverifiedShield'
);
const VerifiedShieldIcon = createSvgIcon(
<path d="M12.4836 2C13.6166 2 19.5616 4.041 20.3486 4.828C21.0046 5.484 20.9946 6.014 20.9486 8.554C20.9306 9.572 20.9056 10.959 20.9056 12.879C20.9056 19.761 13.0356 22.223 12.7006 22.324C12.6296 22.346 12.5566 22.356 12.4836 22.356C12.4106 22.356 12.3376 22.346 12.2666 22.324C11.9316 22.223 4.06162 19.761 4.06162 12.879C4.06162 10.962 4.03662 9.575 4.01862 8.557C4.01041 8.10289 4.00334 7.71298 4.00102 7.37507L4.00073 6.99377C4.00931 5.74113 4.12687 5.32 4.61962 4.828C5.40462 4.041 11.3496 2 12.4836 2ZM12.4836 3.5C11.6356 3.5 6.28562 5.384 5.66862 5.899C5.48662 6.082 5.47962 6.4 5.51862 8.529C5.53662 9.555 5.56162 10.949 5.56162 12.879C5.56162 18.08 11.2836 20.389 12.4826 20.814C13.6806 20.387 19.4056 18.065 19.4056 12.879C19.4056 10.947 19.4306 9.552 19.4496 8.526C19.4876 6.399 19.4806 6.081 19.2876 5.889C18.6826 5.384 13.3316 3.5 12.4836 3.5ZM16.2051 9.3395C16.4981 9.6325 16.4981 10.1075 16.2051 10.4005L12.3071 14.2995C12.1951 14.4123 12.0505 14.4854 11.8952 14.5102L11.7771 14.5195C11.5781 14.5195 11.3871 14.4405 11.2461 14.2995L9.35412 12.4055C9.06212 12.1125 9.06212 11.6365 9.35512 11.3445C9.64712 11.0515 10.1231 11.0515 10.4161 11.3445L11.7771 12.7075L15.1451 9.3395C15.4381 9.0465 15.9121 9.0465 16.2051 9.3395Z" />,
const CVerifiedShieldIcon = createSvgIcon(
<path d="M11.8,13.64a1.85,1.85,0,0,1,0,.25.9.9,0,0,1,0,.18.33.33,0,0,1,0,.12.47.47,0,0,1-.09.12,1.25,1.25,0,0,1-.25.18c-.13.07-.28.13-.45.2a4.13,4.13,0,0,1-.61.16,4.35,4.35,0,0,1-.74.06,3.93,3.93,0,0,1-1.41-.24A2.91,2.91,0,0,1,7.1,14a3.32,3.32,0,0,1-.67-1.2A5.2,5.2,0,0,1,6.2,11.1a5.37,5.37,0,0,1,.25-1.72,3.85,3.85,0,0,1,.72-1.26,3,3,0,0,1,1.12-.77,3.63,3.63,0,0,1,1.42-.26,3,3,0,0,1,.61,0,2.66,2.66,0,0,1,.54.14,2.34,2.34,0,0,1,.45.19,1.84,1.84,0,0,1,.28.19l.11.13s0,.09.05.14,0,.12,0,.19a2.35,2.35,0,0,1,0,.28,2.63,2.63,0,0,1,0,.3.88.88,0,0,1,0,.2.52.52,0,0,1-.07.11.17.17,0,0,1-.1,0,.38.38,0,0,1-.22-.1c-.09-.07-.21-.14-.35-.23a3.08,3.08,0,0,0-.51-.23,2.41,2.41,0,0,0-.7-.1A1.67,1.67,0,0,0,9,8.57a1.58,1.58,0,0,0-.6.52A2.54,2.54,0,0,0,8,9.92,4.15,4.15,0,0,0,7.86,11,4.31,4.31,0,0,0,8,12.17a2.19,2.19,0,0,0,.39.81,1.55,1.55,0,0,0,.61.47,2,2,0,0,0,.82.16,2.17,2.17,0,0,0,.71-.1A2.45,2.45,0,0,0,11,13.3l.35-.21a.38.38,0,0,1,.21-.1.17.17,0,0,1,.1,0,.17.17,0,0,1,.06.09c0,.05,0,.11,0,.2A3,3,0,0,1,11.8,13.64ZM9,0,0,4v6c0,5.55,3.84,10.74,9,12,5.16-1.26,9-6.45,9-12V4Zm7,10a10.47,10.47,0,0,1-7,9.93A10.47,10.47,0,0,1,2,10V5.3L9,2.19,16,5.3Z" />,
'VerifiedShield'
);
const NVerifiedShieldIcon = createSvgIcon(
<path d="M12.13,14.25a.6.6,0,0,1-.05.24.45.45,0,0,1-.13.17.39.39,0,0,1-.19.1.52.52,0,0,1-.21,0h-.66a1.79,1.79,0,0,1-.36,0,.72.72,0,0,1-.27-.15,1.06,1.06,0,0,1-.24-.3c-.08-.12-.17-.28-.27-.47L7.87,10.29c-.11-.21-.22-.44-.34-.68s-.21-.48-.3-.71h0l0,.84c0,.28,0,.56,0,.86v4a.17.17,0,0,1,0,.1.19.19,0,0,1-.11.08.81.81,0,0,1-.21.05l-.35,0-.34,0A.81.81,0,0,1,6,14.75a.19.19,0,0,1-.11-.08.17.17,0,0,1,0-.1V7.75A.51.51,0,0,1,6,7.34a.56.56,0,0,1,.39-.14h.83a1.82,1.82,0,0,1,.37,0,.84.84,0,0,1,.27.13,1.06,1.06,0,0,1,.23.24A3.9,3.9,0,0,1,8.35,8l1.47,2.78.26.49.24.49.23.47c.07.16.15.32.22.47h0c0-.27,0-.56,0-.85s0-.58,0-.85V7.43a.17.17,0,0,1,0-.1.29.29,0,0,1,.12-.09.9.9,0,0,1,.22,0h.68a.72.72,0,0,1,.2,0s.09,0,.11.09a.17.17,0,0,1,0,.1ZM9,0,0,4v6c0,5.55,3.84,10.74,9,12,5.16-1.26,9-6.45,9-12V4Zm7,10a10.47,10.47,0,0,1-7,9.93A10.47,10.47,0,0,1,2,10V5.3L9,2.19,16,5.3Z" />,
'VerifiedShield'
);
@ -222,8 +227,9 @@ const CriticalVulnerabilityChip = () => {
const UnverifiedSignatureIcon = ({ signatureInfo }) => {
return (
<Tooltip title={<SignatureTooltip isSigned={false} signatureInfo={signatureInfo} />} placement="top">
<Tooltip title={<SignatureTooltip signatureInfo={signatureInfo} />} placement="top">
<UnverifiedShieldIcon
viewBox="0 0 18 22"
sx={{
color: '#E53935',
padding: '0.2rem',
@ -237,22 +243,119 @@ const UnverifiedSignatureIcon = ({ signatureInfo }) => {
</Tooltip>
);
};
const NotTrustedSignatureIcon = ({ signatureInfo }) => {
return (
<Tooltip title={<SignatureTooltip signatureInfo={signatureInfo} />} placement="top">
{(signatureInfo[0]?.tool && signatureInfo[0].tool == filterConstants.signatureToolConstants.NOTATION && (
<Badge
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right'
}}
overlap="circular"
color="warning"
badgeContent={signatureInfo.length}
>
<NVerifiedShieldIcon
viewBox="0 0 18 22"
sx={{
backgroundColor: '#FCE2B8!important',
color: '#FB8C00',
alignSelf: 'center',
padding: '0.2rem',
background: '#E8F5E9',
borderRadius: '1rem',
height: '1.5rem',
width: '1.6rem'
}}
data-testid="untrusted-icon"
/>
</Badge>
)) ||
(signatureInfo[0]?.tool && signatureInfo[0].tool == filterConstants.signatureToolConstants.COSIGN && (
<Badge
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right'
}}
overlap="circular"
color="warning"
badgeContent={signatureInfo.length}
>
<CVerifiedShieldIcon
viewBox="0 0 18 22"
sx={{
backgroundColor: '#FCE2B8!important',
color: '#FB8C00',
alignSelf: 'center',
padding: '0.2rem',
background: '#E8F5E9',
borderRadius: '1rem',
height: '1.5rem',
width: '1.6rem'
}}
data-testid="untrusted-icon"
/>
</Badge>
))}
</Tooltip>
);
};
const VerifiedSignatureIcon = ({ signatureInfo }) => {
return (
<Tooltip title={<SignatureTooltip isSigned={true} signatureInfo={signatureInfo} />} placement="top">
<VerifiedShieldIcon
viewBox="0 0 24 24"
sx={{
color: '#43A047',
alignSelf: 'center',
padding: '0.2rem',
background: '#E8F5E9',
borderRadius: '1rem',
height: '1.5rem',
width: '1.6rem'
}}
data-testid="verified-icon"
/>
<Tooltip title={<SignatureTooltip signatureInfo={signatureInfo} />} placement="top">
{(signatureInfo[0]?.tool && signatureInfo[0].tool == filterConstants.signatureToolConstants.NOTATION && (
<Badge
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right'
}}
overlap="circular"
color="success"
badgeContent={signatureInfo.length}
>
<NVerifiedShieldIcon
viewBox="0 0 18 22"
sx={{
color: '#43A047',
alignSelf: 'center',
padding: '0.2rem',
background: '#E8F5E9',
borderRadius: '1rem',
height: '1.5rem',
width: '1.6rem'
}}
data-testid="verified-icon"
/>
</Badge>
)) ||
(signatureInfo[0]?.tool && signatureInfo[0].tool == filterConstants.signatureToolConstants.COSIGN && (
<Badge
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right'
}}
overlap="circular"
color="success"
badgeContent={signatureInfo.length}
>
<CVerifiedShieldIcon
viewBox="0 0 18 22"
sx={{
color: '#43A047',
alignSelf: 'center',
padding: '0.2rem',
background: '#E8F5E9',
borderRadius: '1rem',
height: '1.5rem',
width: '1.6rem'
}}
data-testid="verified-icon"
/>
</Badge>
))}
</Tooltip>
);
};
@ -270,19 +373,6 @@ const UnverifiedSignatureChip = () => {
/>
);
};
const VerifiedSignatureChip = () => {
return (
<Chip
label="Verified Signature"
sx={{ backgroundColor: '#E8F5E9', color: '#388E3C', fontSize: '0.8125rem' }}
variant="filled"
onDelete={() => {
return;
}}
deleteIcon={<VerifiedShieldIcon sx={{ color: '#388E3C!important' }} />}
/>
);
};
export {
NoneVulnerabilityIcon,
@ -298,9 +388,9 @@ export {
HighVulnerabilityChip,
CriticalVulnerabilityChip,
UnverifiedSignatureIcon,
NotTrustedSignatureIcon,
VerifiedSignatureIcon,
UnverifiedSignatureChip,
VerifiedSignatureChip,
FailedScanIcon,
FailedScanChip
};

View File

@ -222,8 +222,8 @@ fi
trivy_out_file=trivy-${image}-${tag}.json
if [ ! -z "${multiarch}" ]; then
trivy image --scanners vuln --format json --input ${local_image_ref_trivy} -o ${trivy_out_file}
jq -n --argfile trivy_file ${trivy_out_file} '.trivy=$trivy_file.Results' > ${trivy_out_file}.tmp
trivy image --scanners vuln --db-repository ghcr.io/project-zot/trivy-db --format json --input ${local_image_ref_trivy} -o ${trivy_out_file}
jq -n --slurpfile trivy_file ${trivy_out_file} '.trivy=$trivy_file[0].Results' > ${trivy_out_file}.tmp
mv ${trivy_out_file}.tmp ${trivy_out_file}
else
echo '{"trivy":[]}' > ${trivy_out_file}

View File

@ -29,7 +29,7 @@ test.describe('Tag page test', () => {
await expect(page.getByTestId('layer-card-container').locator('div').nth(1)).toBeVisible({ timeout: 100000 });
await page.getByRole('tab', { name: 'Uses' }).click();
await expect(page.getByTestId('depends-on-container').locator('div').nth(1)).toBeVisible({ timeout: 100000 });
await expect(page.getByText('Tag')).toHaveCount(1, { timeout: 100000 });
await expect(await page.getByText('Tag').count()).toBeGreaterThan(0);
});
test('Tag page with vulnerabilities', async ({ page }) => {
@ -37,8 +37,8 @@ test.describe('Tag page test', () => {
await page.goto(`${hosts.ui}/image/${tagWithVulnerabilities.title}/tag/${tagWithVulnerabilities.tag}`);
await page.getByRole('tab', { name: 'Vulnerabilities' }).click();
await expect(page.getByTestId('vulnerability-container').locator('div').nth(1)).toBeVisible({ timeout: 100000 });
await expect(page.getByText('CVE-').nth(0)).toBeVisible({ timeout: 100000 });
await expect(await page.getByText('CVE-').count()).toBeGreaterThan(0);
await expect(await page.getByText('CVE-').count()).toBeLessThanOrEqual(pageSizes.EXPLORE);
await expect(page.getByText(/CVE-/).nth(0)).toBeVisible({ timeout: 100000 });
await expect(await page.getByText(/CVE-/).count()).toBeGreaterThan(0);
await expect(await page.getByText(/CVE-/).count()).toBeLessThanOrEqual(pageSizes.EXPLORE);
});
});

View File

@ -149,7 +149,7 @@ const getRepoListOrderedAlpha = () => {
// };
const getRepoCardNameForLocator = (repo) => {
return `${repo?.repo} ${repo?.tags[0]?.description?.slice(0, 10)}`;
return new RegExp(`${repo?.repo} \\d${repo?.tags[0]?.description?.slice(0, 10)}`);
};
export {

View File

@ -25,7 +25,7 @@ const endpoints = {
10 * (pageNumber - 1)
}%20sortBy:%20${sortCriteria}}%20)%20{Page%20{TotalCount%20ItemCount}%20Repos%20{Name%20LastUpdated%20Size%20Platforms%20{%20Os%20Arch%20}%20IsStarred%20IsBookmarked%20NewestImage%20{%20Tag%20Vulnerabilities%20{MaxSeverity%20Count}%20Description%20IsSigned%20SignatureInfo%20{%20Tool%20IsTrusted%20Author%20}%20Licenses%20Vendor%20Labels%20}%20StarCount%20DownloadCount}}}`,
image: (name) =>
`/v2/_zot/ext/search?query={Image(image:%20%22${name}%22){RepoName%20IsSigned%20SignatureInfo%20{%20Tool%20IsTrusted%20Author%20}%20Vulnerabilities%20{MaxSeverity%20Count}%20%20Referrers%20{MediaType%20ArtifactType%20Size%20Digest%20Annotations{Key%20Value}}%20Tag%20Manifests%20{History%20{Layer%20{Size%20Digest}%20HistoryDescription%20{CreatedBy%20EmptyLayer}}%20Digest%20ConfigDigest%20LastUpdated%20Size%20Platform%20{Os%20Arch}}%20Vendor%20Licenses%20}}`
`/v2/_zot/ext/search?query={Image(image:%20%22${name}%22){RepoName%20IsSigned%20SignatureInfo%20{%20Tool%20IsTrusted%20Author%20}%20Vulnerabilities%20{MaxSeverity%20Count}%20%20Referrers%20{MediaType%20ArtifactType%20Size%20Digest%20Annotations{Key%20Value}}%20Tag%20Manifests%20{History%20{Layer%20{Size%20Digest}%20HistoryDescription%20{CreatedBy%20EmptyLayer}}%20Digest%20ConfigDigest%20LastUpdated%20Size%20Platform%20{Os%20Arch}%20Referrers%20{MediaType%20ArtifactType%20Size%20Digest%20Annotations{Key%20Value}}}%20Vendor%20Licenses%20}}`
};
export { hosts, endpoints, sortCriteria, pageSizes };