49 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
5bf7d5652c feat: add cve summary in vulnerability tab (#416)
Signed-off-by: Laurentiu Niculae <niculae.laurentiu1@gmail.com>
2024-01-18 22:00:19 +02:00
12f9229320 fix(export vuln): change sheet name and download options name (#417)
Signed-off-by: Andreea-Lupu <andreealupu1470@yahoo.com>
2024-01-18 15:41:18 +02:00
df19fa811c feat: add expand/collapse view list buttons for vulnerabilities (#409)
Signed-off-by: Andreea-Lupu <andreealupu1470@yahoo.com>
2024-01-17 15:38:39 +02:00
6cda89c710 fix: change 'csv' to 'CSV' in the vulnerabilities download options list (#413)
Signed-off-by: Andreea-Lupu <andreealupu1470@yahoo.com>
2024-01-17 14:16:46 +02:00
12b474e126 fix: update zot documentation urls (#411)
resolves #410

Signed-off-by: Andrei Aaron <aaaron@luxoft.com>
2024-01-12 13:47:16 +02:00
a9db66bd34 feat: add freebsd as an OS filter (#407) (#408)
Signed-off-by: Doug Rabson <dfr@rabson.org>
2023-12-28 14:52:27 +02:00
f4600b8b79 feat: export vulnerabilities list
Signed-off-by: Andreea-Lupu <andreealupu1470@yahoo.com>
2023-12-20 09:23:47 -08:00
c375c0697a feat: added button to delete tag
Signed-off-by: Petu Eusebiu <peusebiu@cisco.com>
2023-12-15 15:32:28 -08:00
2e1e2e92b7 fix: show a loading message while waiting for a response
Signed-off-by: Andreea-Lupu <andreealupu1470@yahoo.com>
2023-12-11 13:14:30 -08:00
d9370fb9c1 feat: starred repos implementation (#399)
Signed-off-by: Andreea-Lupu <andreealupu1470@yahoo.com>
2023-12-07 16:36:54 +02:00
e97e04eee5 ci: dco job should run only on PRs (#396)
See also message in b919279eef

Signed-off-by: Andrei Aaron <aaaron@luxoft.com>
2023-11-28 11:23:15 +02:00
a288523a3f feat: vulnerability chips - show icon before string (#392)
Signed-off-by: Andreea-Lupu <andreealupu1470@yahoo.com>
2023-11-28 10:51:23 +02:00
fad5572db4 feat: add prefix zot to /auth urls (#389)
See: https://github.com/project-zot/zot/issues/1883

Signed-off-by: Andrei Aaron <aaaron@luxoft.com>
2023-10-20 13:17:24 +03:00
19e366ee1f fix: use the official icon
Signed-off-by: Ramkumar Chinchani <rchincha@cisco.com>
2023-10-11 19:56:02 -07:00
b41fb2f841 patch: update nodata display on homepage
Signed-off-by: Raul-Cristian Kele <raulkeleblk@gmail.com>
2023-10-02 14:16:15 -07:00
b787273b84 fix: fixed display of new signature tooltips in some cases (#379)
Signed-off-by: Raul-Cristian Kele <raulkeleblk@gmail.com>
2023-08-29 19:46:13 +03:00
9ecd46e4d0 ci(end-to-end): Fix a few issues with the workflow (#380)
- free up disk space before running tests
- remove uneeded call to /v2/_catalog.
- add a check to make sure the images are scanned for CVEs before tests start

Signed-off-by: Andrei Aaron <aaaron@luxoft.com>
2023-08-29 19:09:52 +03:00
845726cd08 feat: Update signature integration to display extra info (#378)
Signed-off-by: Raul-Cristian Kele <raulkeleblk@gmail.com>
2023-08-28 18:03:32 +03:00
ac84c375c0 feat: add customizable generic oidc login button
Rebased and modified to reflect https://github.com/project-zot/zot/pull/1691 conclusion

Signed-off-by: Damien Degois <damien@degois.info>
2023-08-28 15:15:17 +03:00
96008d67be feat: Implement no data component
- Implement customizeable component for no data display
- Added component to homepage

Signed-off-by: Raul-Cristian Kele <raulkeleblk@gmail.com>
2023-08-18 20:09:28 +03:00
087b42693f patch: update integration tests
Signed-off-by: raulkele <raulkeleblk@gmail.com>
Signed-off-by: Raul-Cristian Kele <raulkeleblk@gmail.com>
2023-08-15 20:12:30 +03:00
8f4c23bf40 fix: Update tooltip for vulnerability chips
Signed-off-by: Raul-Cristian Kele <raulkeleblk@gmail.com>
2023-08-08 21:33:22 +03:00
54c764c996 patch: update cve api usage
- updated CVEListForImage api calls
- updated ImageListWithCVEFixed api calls
- now cves are shown for specific tag
- fixed tags now only shows tags that match platform with current digest
- moved platform selector on tagdetails page

Signed-off-by: Raul-Cristian Kele <raulkeleblk@gmail.com>
2023-07-27 08:51:27 +03:00
44289c751f fix: fixed login page refresh bug
Signed-off-by: Raul-Cristian Kele <raulkeleblk@gmail.com>
2023-07-19 17:44:44 +03:00
87 changed files with 13091 additions and 6887 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

@ -2,16 +2,18 @@
name: DCO
on:
pull_request:
push:
branches:
- main
permissions: read-all
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Set up Python 3.x
uses: actions/setup-python@v1
uses: actions/setup-python@v4
with:
python-version: '3.x'
- name: Check DCO

View File

@ -18,20 +18,28 @@ jobs:
name: Test zui/zot integration
env:
CI: ""
REGISTRY_HOST: "localhost"
REGISTRY_HOST: "127.0.0.1"
REGISTRY_PORT: "8080"
runs-on: ubuntu-latest
steps:
- name: Cleanup disk space
run: |
# To free up ~15 GB of disk space
sudo rm -rf /opt/ghc
sudo rm -rf /usr/local/share/boost
sudo rm -rf /usr/local/lib/android
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
@ -73,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
@ -86,7 +94,7 @@ jobs:
- name: Build zot
run: |
cd $GITHUB_WORKSPACE/zot
make binary
make binary ZUI_BUILD_PATH=$GITHUB_WORKSPACE/build
ls -l bin/
- name: Bringup zot server
@ -116,13 +124,19 @@ jobs:
cd $GITHUB_WORKSPACE
make playwright-browsers
- name: Trigger CVE scanning
run: |
# trigger CVE scanning for all images before running the tests
curl -X POST -H "Content-Type: application/json" -m 600 --data '{ "query": "{ ImageListForCVE (id:\"CVE-2021-43616\") { Results { RepoName Tag } } }" }' http://$REGISTRY_HOST:$REGISTRY_PORT/v2/_zot/ext/search
- name: Run integration tests
run: |
cd $GITHUB_WORKSPACE
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'
}
}
];

15953
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,56 +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.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",
"web-vitals": "^2.1.3"
"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": {
"@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",
"react-scripts": "^5.0.1",
"@babel/plugin-proposal-private-property-in-object": "^7.16.7"
"@babel/plugin-proposal-private-property-in-object": "^7.16.7",
"@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}
@ -42,7 +41,8 @@ const config = {
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
ignoreHTTPSErrors: true
ignoreHTTPSErrors: true,
screenshot: 'only-on-failure'
},
/* Configure projects for major browsers */
@ -52,7 +52,7 @@ const config = {
use: {
...devices['Desktop Chrome'],
ignoreHTTPSErrors: true
},
}
},
{
@ -60,7 +60,7 @@ const config = {
use: {
...devices['Desktop Firefox'],
ignoreHTTPSErrors: true
},
}
},
{
@ -68,8 +68,8 @@ const config = {
use: {
...devices['Desktop Safari'],
ignoreHTTPSErrors: true
},
},
}
}
/* Test against mobile viewports. */
// {
@ -101,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
}));
@ -34,10 +34,11 @@ const mockImageList = {
Size: '2806985',
LastUpdated: '2022-08-09T17:19:53.274069586Z',
IsBookmarked: false,
IsStarred: false,
NewestImage: {
Tag: 'latest',
Description: 'w',
IsSigned: false,
SignatureInfo: [],
Licenses: '',
Vendor: '',
Labels: '',
@ -58,10 +59,22 @@ const mockImageList = {
Size: '231383863',
LastUpdated: '2022-08-02T01:30:49.193203152Z',
IsBookmarked: false,
IsStarred: false,
NewestImage: {
Tag: 'latest',
Description: '',
IsSigned: true,
SignatureInfo: [
{
Tool: 'cosign',
IsTrusted: false,
Author: ''
},
{
Tool: 'notation',
IsTrusted: false,
Author: ''
}
],
Licenses: '',
Vendor: '',
Labels: '',
@ -82,10 +95,22 @@ const mockImageList = {
Size: '369311301',
LastUpdated: '2022-08-23T00:20:40.144281895Z',
IsBookmarked: false,
IsStarred: false,
NewestImage: {
Tag: 'latest',
Description: '',
IsSigned: true,
SignatureInfo: [
{
Tool: 'cosign',
IsTrusted: true,
Author: ''
},
{
Tool: 'notation',
IsTrusted: true,
Author: ''
}
],
Licenses: '',
Vendor: '',
Labels: '',
@ -106,10 +131,22 @@ const mockImageList = {
Size: '369311301',
LastUpdated: '2022-08-23T00:20:40.144281895Z',
IsBookmarked: false,
IsStarred: false,
NewestImage: {
Tag: 'latest',
Description: '',
IsSigned: true,
SignatureInfo: [
{
Tool: 'cosign',
IsTrusted: true,
Author: ''
},
{
Tool: 'notation',
IsTrusted: true,
Author: ''
}
],
Licenses: '',
Vendor: '',
Labels: '',
@ -130,10 +167,22 @@ const mockImageList = {
Size: '369311301',
LastUpdated: '2022-08-23T00:20:40.144281895Z',
IsBookmarked: false,
IsStarred: false,
NewestImage: {
Tag: 'latest',
Description: '',
IsSigned: true,
SignatureInfo: [
{
Tool: 'cosign',
IsTrusted: true,
Author: ''
},
{
Tool: 'notation',
IsTrusted: true,
Author: ''
}
],
Licenses: '',
Vendor: '',
Labels: '',
@ -158,10 +207,22 @@ const mockImageList = {
Size: '369311301',
LastUpdated: '2022-08-23T00:20:40.144281895Z',
IsBookmarked: false,
IsStarred: false,
NewestImage: {
Tag: 'latest',
Description: '',
IsSigned: true,
SignatureInfo: [
{
Tool: 'cosign',
IsTrusted: true,
Author: ''
},
{
Tool: 'notation',
IsTrusted: true,
Author: ''
}
],
Licenses: '',
Vendor: '',
Labels: '',
@ -182,10 +243,22 @@ const mockImageList = {
Size: '369311301',
LastUpdated: '2022-08-23T00:20:40.144281895Z',
IsBookmarked: false,
IsStarred: false,
NewestImage: {
Tag: 'latest',
Description: '',
IsSigned: true,
SignatureInfo: [
{
Tool: 'cosign',
IsTrusted: true,
Author: 'author1'
},
{
Tool: 'notation',
IsTrusted: true,
Author: 'author2'
}
],
Licenses: '',
Vendor: '',
Labels: '',
@ -218,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 },
@ -266,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 () => {
@ -338,4 +426,13 @@ describe('Explore component', () => {
await userEvent.click(bookmarkButton);
expect(await screen.findAllByTestId('bookmarked')).toHaveLength(1);
});
it('should star a repo if star button is clicked', async () => {
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
render(<StateExploreWrapper />);
const starButton = (await screen.findAllByTestId('star-button'))[0];
jest.spyOn(api, 'put').mockResolvedValueOnce({ status: 200, data: {} });
await userEvent.click(starButton);
expect(await screen.findAllByTestId('starred')).toHaveLength(1);
});
});

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

@ -21,7 +21,7 @@ const StateFilterCardWrapper = () => {
describe('Filters components', () => {
it('renders the filters cards', async () => {
render(<StateFilterCardWrapper />);
expect(screen.getAllByRole('checkbox')).toHaveLength(2);
expect(screen.getAllByRole('checkbox')).toHaveLength(3);
const checkbox = screen.getAllByRole('checkbox');
expect(checkbox[0]).not.toBeChecked();

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: '',
@ -164,6 +197,48 @@ const mockImageListBookmarks = {
}
};
const mockImageListStars = {
GlobalSearch: {
Page: { TotalCount: 3, ItemCount: 2 },
Repos: [
{
Name: 'alpine',
Size: '2806985',
LastUpdated: '2022-08-09T17:19:53.274069586Z',
NewestImage: {
Tag: 'latest',
Description: 'w',
IsSigned: false,
Licenses: '',
Vendor: '',
Labels: '',
Vulnerabilities: {
MaxSeverity: 'LOW',
Count: 7
}
}
},
{
Name: 'mongo',
Size: '231383863',
LastUpdated: '2022-08-02T01:30:49.193203152Z',
NewestImage: {
Tag: 'latest',
Description: '',
IsSigned: true,
Licenses: '',
Vendor: '',
Labels: '',
Vulnerabilities: {
MaxSeverity: 'HIGH',
Count: 2
}
}
}
]
}
};
beforeEach(() => {
window.scrollTo = jest.fn();
});
@ -178,8 +253,8 @@ describe('Home component', () => {
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageListRecent } });
render(<HomeWrapper />);
await waitFor(() => expect(screen.getAllByText(/alpine/i)).toHaveLength(3));
await waitFor(() => expect(screen.getAllByText(/mongo/i)).toHaveLength(3));
await waitFor(() => expect(screen.getAllByText(/alpine/i)).toHaveLength(4));
await waitFor(() => expect(screen.getAllByText(/mongo/i)).toHaveLength(4));
await waitFor(() => expect(screen.getAllByText(/node/i)).toHaveLength(1));
});
@ -187,16 +262,16 @@ describe('Home component', () => {
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageListRecent } });
render(<HomeWrapper />);
expect(await screen.findAllByTestId('unverified-icon')).toHaveLength(3);
expect(await screen.findAllByTestId('verified-icon')).toHaveLength(4);
expect(await screen.findAllByTestId('unverified-icon')).toHaveLength(4);
expect(await screen.findAllByTestId('verified-icon')).toHaveLength(10);
});
it('renders vulnerability icons', async () => {
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageListRecent } });
render(<HomeWrapper />);
expect(await screen.findAllByTestId('low-vulnerability-icon')).toHaveLength(3);
expect(await screen.findAllByTestId('high-vulnerability-icon')).toHaveLength(3);
expect(await screen.findAllByTestId('low-vulnerability-icon')).toHaveLength(4);
expect(await screen.findAllByTestId('high-vulnerability-icon')).toHaveLength(4);
expect(await screen.findAllByTestId('critical-vulnerability-icon')).toHaveLength(1);
});
@ -204,16 +279,17 @@ describe('Home component', () => {
jest.spyOn(api, 'get').mockRejectedValue({ status: 500, data: {} });
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
render(<HomeWrapper />);
await waitFor(() => expect(error).toBeCalledTimes(3));
await waitFor(() => expect(error).toBeCalledTimes(4));
});
it('should redirect to explore page when clicking view all popular', async () => {
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageListRecent } });
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageListBookmarks } });
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageListStars } });
render(<HomeWrapper />);
const viewAllButtons = await screen.findAllByText(/view all/i);
expect(viewAllButtons).toHaveLength(3);
expect(viewAllButtons).toHaveLength(4);
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: [] } });
fireEvent.click(viewAllButtons[0]);
expect(mockedUsedNavigate).toHaveBeenCalledWith({
@ -230,5 +306,10 @@ describe('Home component', () => {
pathname: `/explore`,
search: createSearchParams({ filter: 'IsBookmarked' }).toString()
});
fireEvent.click(viewAllButtons[3]);
expect(mockedUsedNavigate).toHaveBeenCalledWith({
pathname: `/explore`,
search: createSearchParams({ filter: 'IsStarred' }).toString()
});
});
});

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' };
},
@ -47,9 +47,22 @@ const mockRepoDetailsData = {
Size: '451554070',
Vendors: ['[The Node.js Docker Team](https://github.com/nodejs/docker-node)\n'],
IsBookmarked: false,
IsStarred: false,
NewestImage: {
RepoName: 'mongo',
IsSigned: true,
SignatureInfo: [
{
Tool: 'cosign',
IsTrusted: true,
Author: 'author1'
},
{
Tool: 'notation',
IsTrusted: true,
Author: 'author2'
}
],
Vulnerabilities: {
MaxSeverity: 'CRITICAL',
Count: 15
@ -284,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(() => {});
@ -316,4 +343,13 @@ describe('Repo details component', () => {
await userEvent.click(bookmarkButton);
expect(await screen.findByTestId('bookmarked')).toBeInTheDocument();
});
it('should star a repo if star button is clicked', async () => {
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockRepoDetailsData } });
render(<RepoDetailsThemeWrapper />);
const starButton = await screen.findByTestId('star-button');
jest.spyOn(api, 'put').mockResolvedValue({ status: 200, data: {} });
await userEvent.click(starButton);
expect(await screen.findByTestId('starred')).toBeInTheDocument();
});
});

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
}));
@ -22,6 +22,7 @@ const mockedTagsData = [
{
tag: 'latest',
vendor: 'test1',
isDeletable: true,
manifests: [
{
lastUpdated: '2022-07-19T18:06:18.818788283Z',
@ -37,6 +38,7 @@ const mockedTagsData = [
{
tag: 'bullseye',
vendor: 'test1',
isDeletable: true,
manifests: [
{
digest: 'sha256:adca4815c494becc1bf053af0c4640b2d81ab1a779e6d649e1b8b92a75f1d559',
@ -52,6 +54,7 @@ const mockedTagsData = [
{
tag: '1.5.2',
vendor: 'test1',
isDeletable: true,
manifests: [
{
lastUpdated: '2022-07-19T18:06:18.818788283Z',
@ -76,6 +79,18 @@ 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 navigate to tag page details when tag is clicked', async () => {
render(<TagsThemeWrapper />);
const tagLink = await screen.findByText('latest');
@ -112,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
}));
@ -22,6 +22,29 @@ const mockImage = {
vendor: '',
size: '585',
tags: '',
isSigned: true,
signatureInfo: [
{
Tool: 'cosign',
IsTrusted: false,
Author: ''
},
{
Tool: 'cosign',
IsTrusted: false,
Author: ''
},
{
Tool: 'cosign',
IsTrusted: false,
Author: ''
},
{
Tool: 'cosign',
IsTrusted: false,
Author: ''
}
],
platforms: [{ Os: 'linux', Arch: 'amd64' }]
};
@ -34,6 +57,8 @@ const RepoCardWrapper = (props) => {
version={image.latestVersion}
description={image.description}
vendor={image.vendor}
isSigned={image.isSigned}
signatureInfo={image.signatureInfo}
key={1}
lastUpdated={image.lastUpdated}
platforms={image.platforms}
@ -52,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}`);
});
@ -60,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,12 @@
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');
const StateVulnerabilitiesWrapper = () => {
return (
@ -16,10 +18,18 @@ const StateVulnerabilitiesWrapper = () => {
);
};
const mockCVEList = {
const simpleMockCVEList = {
CVEListForImage: {
Tag: '',
Page: { ItemCount: 20, TotalCount: 20 },
Page: { ItemCount: 2, TotalCount: 2 },
Summary: {
Count: 2,
UnknownCount: 0,
LowCount: 0,
MediumCount: 1,
HighCount: 0,
CriticalCount: 1
},
CVEList: [
{
Id: 'CVE-2020-16156',
@ -29,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'
}
@ -44,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'
}
@ -78,6 +141,7 @@ const mockCVEList = {
PackageList: [
{
Name: 'libgnutls30',
PackagePath: 'Not Specified',
InstalledVersion: '3.6.13-2ubuntu1.6',
FixedVersion: '3.6.13-2ubuntu1.7'
}
@ -92,6 +156,7 @@ const mockCVEList = {
PackageList: [
{
Name: 'libpcre2-8-0',
PackagePath: 'Not Specified',
InstalledVersion: '10.34-7',
FixedVersion: 'Not Specified'
}
@ -106,6 +171,7 @@ const mockCVEList = {
PackageList: [
{
Name: 'libsqlite3-0',
PackagePath: 'Not Specified',
InstalledVersion: '3.31.1-4ubuntu0.3',
FixedVersion: '3.31.1-4ubuntu0.4'
}
@ -120,6 +186,7 @@ const mockCVEList = {
PackageList: [
{
Name: 'libpcre3',
PackagePath: 'Not Specified',
InstalledVersion: '2:8.39-12ubuntu0.1',
FixedVersion: 'Not Specified'
}
@ -134,6 +201,7 @@ const mockCVEList = {
PackageList: [
{
Name: 'libsqlite3-0',
PackagePath: 'Not Specified',
InstalledVersion: '3.31.1-4ubuntu0.3',
FixedVersion: '3.31.1-4ubuntu0.4'
}
@ -148,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'
}
@ -167,6 +237,7 @@ const mockCVEList = {
PackageList: [
{
Name: 'libgmp10',
PackagePath: 'Not Specified',
InstalledVersion: '2:6.2.0+dfsg-4',
FixedVersion: 'Not Specified'
}
@ -181,6 +252,7 @@ const mockCVEList = {
PackageList: [
{
Name: 'libgnutls30',
PackagePath: 'Not Specified',
InstalledVersion: '3.6.13-2ubuntu1.6',
FixedVersion: '3.6.13-2ubuntu1.7'
}
@ -195,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'
}
@ -229,6 +306,7 @@ const mockCVEList = {
PackageList: [
{
Name: 'libpcre2-8-0',
PackagePath: 'Not Specified',
InstalledVersion: '10.34-7',
FixedVersion: 'Not Specified'
}
@ -243,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'
}
@ -277,6 +360,7 @@ const mockCVEList = {
PackageList: [
{
Name: 'coreutils',
PackagePath: 'Not Specified',
InstalledVersion: '8.30-3ubuntu2',
FixedVersion: 'Not Specified'
}
@ -291,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'
}
@ -345,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'
}
@ -363,6 +458,7 @@ const mockCVEList = {
PackageList: [
{
Name: 'libcurl4',
PackagePath: 'Not Specified',
InstalledVersion: '7.68.0-1ubuntu2.12',
FixedVersion: '7.68.0-1ubuntu2.13'
}
@ -378,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'
}
@ -412,6 +513,7 @@ const mockCVEList = {
PackageList: [
{
Name: 'libsqlite3-0',
PackagePath: 'Not Specified',
InstalledVersion: '3.31.1-4ubuntu0.3',
FixedVersion: '3.31.1-4ubuntu0.4'
}
@ -427,6 +529,7 @@ const mockCVEList = {
PackageList: [
{
Name: 'zlib1g',
PackagePath: 'Not Specified',
InstalledVersion: '1:1.2.11.dfsg-2ubuntu1.3',
FixedVersion: 'Not Specified'
}
@ -440,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: {
@ -497,41 +642,100 @@ describe('Vulnerabilties page', () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEList } });
render(<StateVulnerabilitiesWrapper />);
await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1));
await waitFor(() => expect(screen.getAllByText(/fixed in/i)).toHaveLength(20));
await waitFor(() => expect(screen.getAllByText('Total 5')).toHaveLength(1));
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 () => {
jest.spyOn(api, 'get').mockResolvedValue({
status: 200,
data: { data: { CVEListForImage: { Tag: '', Page: {}, CVEList: [] } } }
data: { data: { CVEListForImage: { Tag: '', Page: {}, CVEList: [], Summary: {} } } }
});
render(<StateVulnerabilitiesWrapper />);
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 () => {
@ -549,15 +753,114 @@ 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').mockResolvedValue({ status: 200, data: { data: mockCVEList } });
render(<StateVulnerabilitiesWrapper />);
await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1));
const downloadBtn = await screen.findAllByTestId('DownloadIcon');
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);
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 userEvent.click(exportAsExcelBtn);
expect(await screen.queryByTestId('export-excel-menuItem')).not.toBeInTheDocument();
});
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 } })
.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');
await fireEvent.click(collapseListBtn[0]);
await waitFor(() => expect(screen.queryByText('Fixed in')).not.toBeInTheDocument());
});
it('should handle fixed CVE query errors', async () => {
jest
.spyOn(api, 'get')
@ -566,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

@ -1,11 +1,11 @@
import axios from 'axios';
import { isEmpty } from 'lodash';
import { sortByCriteria } from 'utilities/sortCriteria';
import { logoutUser } from 'utilities/authUtilities';
import { isAuthenticationEnabled, logoutUser } from 'utilities/authUtilities';
import { host } from 'host';
axios.interceptors.request.use((config) => {
if (config.url.includes(endpoints.authConfig)) {
if (config.url.includes(endpoints.authConfig) || !isAuthenticationEnabled()) {
config.withCredentials = false;
} else {
config.headers['X-ZOT-API-CLIENT'] = 'zot-ui';
@ -19,6 +19,7 @@ axios.interceptors.response.use(
},
(error) => {
if (error?.response?.status === 401) {
if (window.location.pathname.includes('/login')) return Promise.reject(error);
logoutUser();
window.location.replace('/login');
return Promise.reject(error);
@ -66,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);
}
};
@ -78,37 +82,61 @@ const api = {
const endpoints = {
status: `/v2/`,
authConfig: `/v2/_zot/ext/mgmt`,
openidAuth: `/auth/login`,
logout: `/auth/logout`,
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:${
(pageNumber - 1) * pageSize
}}){Results {Name LastUpdated Size Platforms {Os Arch} NewestImage { Tag Vulnerabilities {MaxSeverity Count} Description Licenses Title Source IsSigned Documentation Vendor Labels} IsStarred IsBookmarked DownloadCount}}}`,
}}){Results {Name LastUpdated Size Platforms {Os Arch} NewestImage { Tag Vulnerabilities {MaxSeverity Count} Description Licenses Title Source IsSigned SignatureInfo { Tool IsTrusted Author } Documentation Vendor Labels} IsStarred IsBookmarked StarCount DownloadCount}}}`,
detailedRepoInfo: (name) =>
`/v2/_zot/ext/search?query={ExpandedRepoInfo(repo:"${name}"){Images {Manifests {Digest Platform {Os Arch} Size} Vulnerabilities {MaxSeverity Count} Tag LastUpdated Vendor } Summary {Name LastUpdated Size Platforms {Os Arch} Vendors IsStarred IsBookmarked NewestImage {RepoName IsSigned Vulnerabilities {MaxSeverity Count} Manifests {Digest} Tag Vendor Title Documentation DownloadCount Source Description Licenses}}}}`,
`/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 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}}}}`;
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}}}`;
},
imageListWithCVEFixed: (cveId, repoName, { pageNumber = 1, pageSize = 3 }) =>
`/v2/_zot/ext/search?query={ImageListWithCVEFixed(id:"${cveId}", image:"${repoName}", requestedPage: {limit:${pageSize} offset:${
allVulnerabilitiesForRepo: (name) =>
`/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) {
filterParam = `,filter:{`;
if (filter.Os) filterParam += ` Os:${!isEmpty(filter.Os) ? `${JSON.stringify(filter.Os)}` : '""'}`;
if (filter.Arch) filterParam += ` Arch:${!isEmpty(filter.Arch) ? `${JSON.stringify(filter.Arch)}` : '""'}`;
filterParam += '}';
}
return `/v2/_zot/ext/search?query={ImageListWithCVEFixed(id:"${cveId}", image:"${repoName}", requestedPage: {limit:${pageSize} offset:${
(pageNumber - 1) * pageSize
}}) {Page {TotalCount ItemCount} Results {Tag}}}`,
}}${filterParam}) {Page {TotalCount ItemCount} Results {Tag}}}`;
},
dependsOnForImage: (name, { pageNumber = 1, pageSize = 15 } = {}) =>
`/v2/_zot/ext/search?query={BaseImageList(image: "${name}", requestedPage: {limit:${pageSize} offset:${
(pageNumber - 1) * pageSize
}}){Page {TotalCount ItemCount} Results { RepoName Tag Description Manifests {Digest Platform {Os Arch} Size} Vendor DownloadCount LastUpdated IsSigned Vulnerabilities {MaxSeverity Count}}}}`,
}}){Page {TotalCount ItemCount} Results { RepoName Tag Description Manifests {Digest Platform {Os Arch} Size} Vendor DownloadCount LastUpdated IsSigned SignatureInfo { Tool IsTrusted Author } Vulnerabilities {MaxSeverity Count}}}}`,
isDependentOnForImage: (name, { pageNumber = 1, pageSize = 15 } = {}) =>
`/v2/_zot/ext/search?query={DerivedImageList(image: "${name}", requestedPage: {limit:${pageSize} offset:${
(pageNumber - 1) * pageSize
}}){Page {TotalCount ItemCount} Results {RepoName Tag Description Manifests {Digest Platform {Os Arch} Size} Vendor DownloadCount LastUpdated IsSigned Vulnerabilities {MaxSeverity Count}}}}`,
}}){Page {TotalCount ItemCount} Results {RepoName Tag Description Manifests {Digest Platform {Os Arch} Size} Vendor DownloadCount LastUpdated IsSigned SignatureInfo { Tool IsTrusted Author } Vulnerabilities {MaxSeverity Count}}}}`,
globalSearch: ({
searchQuery = '""',
pageNumber = 1,
@ -125,9 +153,10 @@ const endpoints = {
if (filter.Arch) filterParam += ` Arch:${!isEmpty(filter.Arch) ? `${JSON.stringify(filter.Arch)}` : '""'}`;
if (filter.HasToBeSigned) filterParam += ` HasToBeSigned: ${filter.HasToBeSigned}`;
if (filter.IsBookmarked) filterParam += ` IsBookmarked: ${filter.IsBookmarked}`;
if (filter.IsStarred) filterParam += ` IsStarred: ${filter.IsStarred}`;
filterParam += '}';
if (Object.keys(filter).length === 0) filterParam = '';
return `/v2/_zot/ext/search?query={GlobalSearch(${searchParam}, ${paginationParam} ${filterParam}) {Page {TotalCount ItemCount} Repos {Name LastUpdated Size Platforms { Os Arch } IsStarred IsBookmarked NewestImage { Tag Vulnerabilities {MaxSeverity Count} Description IsSigned Licenses Vendor Labels } DownloadCount}}}`;
return `/v2/_zot/ext/search?query={GlobalSearch(${searchParam}, ${paginationParam} ${filterParam}) {Page {TotalCount ItemCount} Repos {Name LastUpdated Size Platforms { Os Arch } IsStarred IsBookmarked NewestImage { Tag Vulnerabilities {MaxSeverity Count} Description IsSigned SignatureInfo { Tool IsTrusted Author } Licenses Vendor Labels } StarCount DownloadCount}}}`;
},
imageSuggestions: ({ searchQuery = '""', pageNumber = 1, pageSize = 15 }) => {
const searchParam = searchQuery !== '' ? `query:"${searchQuery}"` : `query:""`;
@ -136,7 +165,8 @@ const endpoints = {
},
referrers: ({ repo, digest, type = '' }) =>
`/v2/_zot/ext/search?query={Referrers(repo: "${repo}" digest: "${digest}" type: "${type}"){MediaType ArtifactType Size Digest Annotations{Key Value}}}`,
bookmarkToggle: (repo) => `/v2/_zot/ext/userprefs?repo=${repo}&action=toggleBookmark`
bookmarkToggle: (repo) => `/v2/_zot/ext/userprefs?repo=${repo}&action=toggleBookmark`,
starToggle: (repo) => `/v2/_zot/ext/userprefs?repo=${repo}&action=toggleStar`
};
export { api, endpoints };

15
src/assets/noData.svg Normal file
View File

@ -0,0 +1,15 @@
<svg width="235" height="240" viewBox="0 0 235 240" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="34.8711" y="16.2017" width="36" height="249.944" rx="18" transform="rotate(-26.7465 34.8711 16.2017)" fill="#F15527" fill-opacity="0.2"/>
<rect x="156.871" y="36.2017" width="36" height="100.235" rx="18" transform="rotate(-26.7465 156.871 36.2017)" fill="#F15527" fill-opacity="0.2"/>
<rect x="0.871094" y="138.037" width="26.745" height="74.4663" rx="13.3725" transform="rotate(-26.7465 0.871094 138.037)" fill="#F15527" fill-opacity="0.2"/>
<g clip-path="url(#clip0_2865_33046)">
<path d="M117.5 199C161.225 199 197 163.225 197 119.5C197 75.775 161.225 40 117.5 40C73.775 40 38 75.775 38 119.5C38 163.225 73.775 199 117.5 199ZM119.885 82.0555C121.475 80.6245 123.383 79.75 125.45 79.75C127.596 79.75 129.425 80.6245 131.094 82.0555C132.605 83.725 133.4 85.633 133.4 87.7C133.4 89.8465 132.605 91.675 131.094 93.3445C129.425 94.855 127.596 95.65 125.45 95.65C123.383 95.65 121.475 94.855 119.885 93.3445C118.375 91.675 117.5 89.8465 117.5 87.7C117.5 85.633 118.375 83.725 119.885 82.0555ZM100.01 119.261C100.01 119.261 117.261 105.588 123.542 105.031C129.425 104.554 128.232 111.311 127.676 114.809L127.597 115.286C126.484 119.5 125.132 124.588 123.78 129.438C120.759 140.488 117.818 151.3 118.534 153.287C119.328 155.991 124.257 152.572 127.835 150.187C128.312 149.869 128.709 149.551 129.107 149.312C129.107 149.312 129.743 148.676 130.379 149.551C130.538 149.789 130.697 150.028 130.856 150.187C131.572 151.3 131.969 151.697 131.015 152.333L130.697 152.492C128.948 153.685 121.475 158.932 118.454 160.84C115.194 162.987 102.713 170.142 104.621 156.229C106.291 146.451 108.517 138.023 110.266 131.425C113.525 119.5 114.956 114.094 107.642 118.785C104.701 120.534 102.951 121.646 101.918 122.362C101.043 122.998 100.964 122.998 100.408 121.965L100.169 121.487L99.7715 120.852C99.215 120.057 99.215 119.977 100.01 119.261Z" fill="#0F2139"/>
<path d="M119.885 82.0555C121.475 80.6245 123.383 79.75 125.45 79.75C127.596 79.75 129.425 80.6245 131.094 82.0555C132.605 83.725 133.4 85.633 133.4 87.7C133.4 89.8465 132.605 91.675 131.094 93.3445C129.425 94.855 127.596 95.65 125.45 95.65C123.383 95.65 121.475 94.855 119.885 93.3445C118.375 91.675 117.5 89.8465 117.5 87.7C117.5 85.633 118.375 83.725 119.885 82.0555Z" fill="white"/>
<path d="M100.01 119.261C100.01 119.261 117.261 105.588 123.542 105.031C129.425 104.554 128.232 111.311 127.676 114.809L127.597 115.286C126.484 119.5 125.132 124.588 123.78 129.438C120.759 140.488 117.818 151.3 118.534 153.287C119.328 155.991 124.257 152.572 127.835 150.187C128.312 149.869 128.709 149.551 129.107 149.312C129.107 149.312 129.743 148.676 130.379 149.551C130.538 149.789 130.697 150.028 130.856 150.187C131.572 151.3 131.969 151.697 131.015 152.333L130.697 152.492C128.948 153.685 121.475 158.932 118.454 160.84C115.194 162.987 102.713 170.142 104.621 156.229C106.291 146.451 108.517 138.023 110.266 131.425C113.525 119.5 114.956 114.094 107.642 118.785C104.701 120.534 102.951 121.646 101.918 122.362C101.043 122.998 100.964 122.998 100.408 121.965L100.169 121.487L99.7715 120.852C99.215 120.057 99.215 119.977 100.01 119.261Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_2865_33046">
<rect width="159" height="159" fill="white" transform="translate(38 40)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

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';
@ -220,8 +220,10 @@ function Explore({ searchInputValue }) {
version={item.latestVersion}
description={item.description}
downloads={item.downloads}
isSigned={item.isSigned}
stars={item.stars}
signatureInfo={item.signatureInfo}
isBookmarked={item.isBookmarked}
isStarred={item.isStarred}
vendor={item.vendor}
platforms={item.platforms}
key={index}

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';
@ -149,14 +149,14 @@ function Header({ setSearchCurrentValue = () => {} }) {
</Link>
</Grid>
<Grid item className={classes.headerLinkContainer}>
<a className={classes.link} href="https://zotregistry.io" target="_blank" rel="noreferrer">
<a className={classes.link} href="https://zotregistry.dev" target="_blank" rel="noreferrer">
Product
</a>
</Grid>
<Grid item className={classes.headerLinkContainer}>
<a
className={classes.link}
href="https://zotregistry.io/v1.4.3/general/concepts/"
href="https://zotregistry.dev/v2.0.0/general/concepts/"
target="_blank"
rel="noreferrer"
>

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() });
}
}
};
@ -295,12 +301,26 @@ function SearchSuggestion({ setSearchCurrentValue = () => {} }) {
<List
{...getMenuProps()}
className={
isOpen && !isLoading && !isFailedSearch
isOpen && !isFailedSearch
? `${classes.resultsWrapper} ${isComponentFocused && classes.resultsWrapperFocused}`
: classes.resultsWrapperHidden
}
>
{isOpen && suggestionData?.length > 0 && renderSuggestions()}
{isOpen && isLoading && !isEmpty(searchQuery) && isEmpty(suggestionData) && (
<>
<ListItem
className={classes.searchItem}
style={{ color: '#52637A', fontSize: '1rem', textOverflow: 'ellipsis' }}
{...getItemProps({ item: '', index: 0 })}
spacing={2}
>
<Stack direction="row" spacing={2}>
<Typography>Loading...</Typography>
</Stack>
</ListItem>
</>
)}
{isOpen && isEmpty(searchQuery) && isEmpty(suggestionData) && (
<>
<ListItem

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,10 +6,16 @@ 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, HOME_RECENT_PAGE_SIZE, HOME_BOOKMARKS_PAGE_SIZE } from 'utilities/paginationConstants';
import {
HOME_POPULAR_PAGE_SIZE,
HOME_RECENT_PAGE_SIZE,
HOME_BOOKMARKS_PAGE_SIZE,
HOME_STARS_PAGE_SIZE
} from 'utilities/paginationConstants';
import { isEmpty } from 'lodash';
import NoDataComponent from 'components/Shared/NoDataComponent';
const useStyles = makeStyles((theme) => ({
gridWrapper: {
@ -88,6 +94,8 @@ function Home() {
const [isLoadingRecent, setIsLoadingRecent] = useState(true);
const [bookmarkData, setBookmarkData] = useState([]);
const [isLoadingBookmarks, setIsLoadingBookmarks] = useState(true);
const [starData, setStarData] = useState([]);
const [isLoadingStars, setIsLoadingStars] = useState(true);
const navigate = useNavigate();
const abortController = useMemo(() => new AbortController(), []);
@ -184,12 +192,44 @@ function Home() {
});
};
const getStars = () => {
setIsLoadingStars(true);
api
.get(
`${host()}${endpoints.globalSearch({
searchQuery: '',
pageNumber: 1,
pageSize: HOME_STARS_PAGE_SIZE,
sortBy: sortByCriteria.relevance?.value,
filter: { IsStarred: true }
})}`,
abortController.signal
)
.then((response) => {
if (response.data && response.data.data) {
let repoList = response.data.data.GlobalSearch.Repos;
let repoData = repoList.map((responseRepo) => {
return mapToRepo(responseRepo);
});
setStarData(repoData);
setIsLoading(false);
setIsLoadingStars(false);
}
})
.catch((e) => {
setIsLoading(false);
setIsLoadingStars(false);
console.error(e);
});
};
useEffect(() => {
window.scrollTo(0, 0);
setIsLoading(true);
getPopularData();
getRecentData();
getBookmarks();
getStars();
return () => {
abortController.abort();
};
@ -199,6 +239,17 @@ function Home() {
navigate({ pathname: `/explore`, search: createSearchParams({ [type]: value }).toString() });
};
const isNoData = () =>
!isLoading &&
!isLoadingBookmarks &&
!isLoadingStars &&
!isLoadingPopular &&
!isLoadingRecent &&
bookmarkData.length === 0 &&
starData.length === 0 &&
popularData.length === 0 &&
recentData.length === 0;
const renderCards = (cardArray) => {
return (
cardArray &&
@ -209,8 +260,10 @@ function Home() {
version={item.latestVersion}
description={item.description}
downloads={item.downloads}
isSigned={item.isSigned}
stars={item.stars}
signatureInfo={item.signatureInfo}
isBookmarked={item.isBookmarked}
isStarred={item.isStarred}
vendor={item.vendor}
platforms={item.platforms}
key={index}
@ -226,68 +279,89 @@ function Home() {
);
};
return (
<>
{isLoading ? (
<Loading />
) : (
<Stack alignItems="center" className={classes.gridWrapper}>
<Stack className={classes.sectionHeaderContainer} sx={{ paddingTop: '3rem' }}>
<div>
<Typography variant="h4" align="left" className={classes.sectionTitle}>
Most popular images
</Typography>
</div>
<div onClick={() => handleClickViewAll('sortby', sortByCriteria.downloads.value)}>
<Typography variant="body2" className={classes.viewAll}>
View all
</Typography>
</div>
</Stack>
{isLoadingPopular ? <Loading /> : renderCards(popularData)}
{/* currently most popular will be by downloads until stars are implemented */}
<Stack className={classes.sectionHeaderContainer}>
<div>
<Typography variant="h4" align="left" className={classes.sectionTitle}>
Recently updated images
</Typography>
</div>
<div>
<Typography
variant="body2"
className={classes.viewAll}
onClick={() => handleClickViewAll('sortby', sortByCriteria.updateTime.value)}
>
View all
</Typography>
</div>
</Stack>
{isLoadingRecent ? <Loading /> : renderCards(recentData)}
{!isEmpty(bookmarkData) && (
<>
<Stack className={classes.sectionHeaderContainer}>
<div>
<Typography variant="h4" align="left" className={classes.sectionTitle}>
Bookmarks
</Typography>
</div>
<div>
<Typography
variant="body2"
className={classes.viewAll}
onClick={() => handleClickViewAll('filter', 'IsBookmarked')}
>
View all
</Typography>
</div>
</Stack>
{isLoadingBookmarks ? <Loading /> : renderCards(bookmarkData)}
</>
)}
const renderContent = () => {
return isNoData() === true ? (
<NoDataComponent text="No images" />
) : (
<Stack alignItems="center" className={classes.gridWrapper}>
<Stack className={classes.sectionHeaderContainer} sx={{ paddingTop: '3rem' }}>
<div>
<Typography variant="h4" align="left" className={classes.sectionTitle}>
Most popular images
</Typography>
</div>
<div onClick={() => handleClickViewAll('sortby', sortByCriteria.downloads.value)}>
<Typography variant="body2" className={classes.viewAll}>
View all
</Typography>
</div>
</Stack>
)}
</>
);
{isLoadingPopular ? <Loading /> : renderCards(popularData, isLoadingPopular)}
{/* currently most popular will be by downloads until stars are implemented */}
<Stack className={classes.sectionHeaderContainer}>
<div>
<Typography variant="h4" align="left" className={classes.sectionTitle}>
Recently updated images
</Typography>
</div>
<div>
<Typography
variant="body2"
className={classes.viewAll}
onClick={() => handleClickViewAll('sortby', sortByCriteria.updateTime.value)}
>
View all
</Typography>
</div>
</Stack>
{isLoadingRecent ? <Loading /> : renderCards(recentData, isLoadingRecent)}
{!isEmpty(bookmarkData) && (
<>
<Stack className={classes.sectionHeaderContainer}>
<div>
<Typography variant="h4" align="left" className={classes.sectionTitle}>
Bookmarks
</Typography>
</div>
<div>
<Typography
variant="body2"
className={classes.viewAll}
onClick={() => handleClickViewAll('filter', 'IsBookmarked')}
>
View all
</Typography>
</div>
</Stack>
{isLoadingBookmarks ? <Loading /> : renderCards(bookmarkData, isLoadingBookmarks)}
</>
)}
{!isEmpty(starData) && (
<>
<Stack className={classes.sectionHeaderContainer}>
<div>
<Typography variant="h4" align="left" className={classes.sectionTitle}>
Stars
</Typography>
</div>
<div>
<Typography
variant="body2"
className={classes.viewAll}
onClick={() => handleClickViewAll('filter', 'IsStarred')}
>
View all
</Typography>
</div>
</Stack>
{isLoadingStars ? <Loading /> : renderCards(starData, isLoadingStars)}
</>
)}
</Stack>
);
};
return <>{isLoading ? <Loading /> : renderContent()}</>;
}
export default Home;

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, DexLoginButton } 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,47 +258,68 @@ 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 isDex = isObject(authMethods.openid?.providers?.dex);
let isOIDC = isObject(authMethods.openid?.providers?.oidc);
let oidcName = authMethods.openid?.providers?.oidc?.name;
return (
<Stack direction="column" spacing="1rem" className={classes.thirdPartyLoginContainer}>
{isGithub && <GithubLoginButton handleClick={handleClickExternalLogin} />}
{isGoogle && <GoogleLoginButton handleClick={handleClickExternalLogin} />}
{/* {isGitlab && <GitlabLoginButton handleClick={handleClickExternalLogin} />} */}
{isDex && <DexLoginButton handleClick={handleClickExternalLogin} />}
{isGitlab && <GitlabLoginButton handleClick={handleClickExternalLogin} />}
{isOIDC && <OIDCLoginButton handleClick={handleClickExternalLogin} oidcName={oidcName} />}
</Stack>
);
};
@ -308,10 +336,16 @@ export default function SignIn({ isLoggedIn, setIsLoggedIn, wrapperSetLoading =
Sign In
</Typography>
<Typography align="left" className={classes.subtext} variant="body1" gutterBottom>
Welcome back! Please enter your details.
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
@ -327,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"
@ -342,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 && (
@ -350,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

@ -1,37 +0,0 @@
import React from 'react';
import { makeStyles } from '@mui/styles';
import { Stack, Typography } from '@mui/material';
const useStyles = makeStyles(() => ({
subtext: {
color: '#52637A',
fontSize: '0.8125rem',
fontWeight: '400',
lineHeight: '154%',
letterSpacing: '0.025rem',
marginBottom: '0'
},
text: {
color: '#0F2139',
fontSize: '0.8125rem',
lineHeight: '154%',
fontWeight: '600',
letterSpacing: '0.025rem'
}
}));
export default function TermsOfService(props) {
const classes = useStyles();
return (
<Stack spacing={0}>
<Typography variant="caption" className={classes.subtext} align="justify" {...props} pb={6}>
By using zot UI, you agree to the Terms of Service. For more information about our privacy practices, see
zot&apos;s Privacy Policy.
</Typography>
<Typography variant="caption" className={classes.text} align="center" {...props}>
Privacy Policy | Terms of Service
</Typography>
</Stack>
);
}

View File

@ -80,14 +80,15 @@ function GitlabLoginButton({ handleClick }) {
);
}
function DexLoginButton({ handleClick }) {
function OIDCLoginButton({ handleClick, oidcName }) {
const classes = useStyles();
const loginWithName = oidcName || 'OIDC';
return (
<Button fullWidth variant="contained" className={classes.button} onClick={(e) => handleClick(e, 'dex')}>
Sign in with Dex
<Button fullWidth variant="contained" className={classes.button} onClick={(e) => handleClick(e, 'oidc')}>
Sign in with {loginWithName}
</Button>
);
}
export { GithubLoginButton, GoogleLoginButton, GitlabLoginButton, DexLoginButton };
export { GithubLoginButton, GoogleLoginButton, GitlabLoginButton, OIDCLoginButton };

View File

@ -8,13 +8,22 @@ 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';
import BookmarkIcon from '@mui/icons-material/Bookmark';
import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder';
import makeStyles from '@mui/styles/makeStyles';
import StarIcon from '@mui/icons-material/Star';
import StarBorderIcon from '@mui/icons-material/StarBorder';
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';
@ -22,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: {
@ -195,6 +198,10 @@ function RepoDetails() {
};
}, [name]);
const handleDeleteTag = (removed) => {
setTags((prevState) => prevState.filter((tag) => tag.tag !== removed));
};
const handlePlatformChipClick = (event) => {
const { textContent } = event.target;
event.stopPropagation();
@ -221,7 +228,7 @@ function RepoDetails() {
const handleBookmarkClick = () => {
api.put(`${host()}${endpoints.bookmarkToggle(name)}`, abortController.signal).then((response) => {
if (response.status === 200) {
if (response && response.status === 200) {
setRepoDetailData((prevState) => ({
...prevState,
isBookmarked: !prevState.isBookmarked
@ -230,6 +237,17 @@ function RepoDetails() {
});
};
const handleStarClick = () => {
api.put(`${host()}${endpoints.starToggle(name)}`, abortController.signal).then((response) => {
if (response.status === 200) {
setRepoDetailData((prevState) => ({
...prevState,
isStarred: !prevState.isStarred
}));
}
});
};
const getVendor = () => {
return `${repoDetailData.newestTag?.Vendor || 'Vendor not available'}`;
};
@ -243,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 ? (
@ -271,17 +311,35 @@ function RepoDetails() {
</Stack>
<Stack alignItems="center" sx={{ width: { xs: '100%', md: 'auto' } }} direction="row" spacing={2}>
<VulnerabilityIconCheck vulnerabilitySeverity={repoDetailData?.vulnerabilitySeverity} />
<SignatureIconCheck isSigned={repoDetailData.isSigned} />
{getSignatureChips()}
</Stack>
<Stack alignItems="center" sx={{ width: { xs: '100%', md: 'auto' } }} direction="row" spacing={1}>
{isAuthenticated() && (
<IconButton component="span" onClick={handleStarClick} data-testid="star-button">
{repoDetailData?.isStarred ? (
<StarIcon data-testid="starred" />
) : (
<StarBorderIcon data-testid="not-starred" />
)}
</IconButton>
)}
{isAuthenticated() && (
<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>
{isAuthenticated() && (
<IconButton component="span" onClick={handleBookmarkClick} data-testid="bookmark-button">
{repoDetailData?.isBookmarked ? (
<BookmarkIcon data-testid="bookmarked" />
) : (
<BookmarkBorderIcon data-testid="not-bookmarked" />
)}
</IconButton>
)}
</Stack>
<Typography gutterBottom className={classes.repoTitle}>
{repoDetailData?.title || 'Title not available'}
@ -314,7 +372,7 @@ function RepoDetails() {
<Grid item xs={12} md={8} className={classes.tags}>
<Card className={classes.cardRoot}>
<CardContent className={classes.tagsContent}>
<Tags tags={tags} />
<Tags tags={tags} repoName={name} onTagDelete={handleDeleteTag} />
</CardContent>
</Card>
</Grid>

View File

@ -43,7 +43,7 @@ const useStyles = makeStyles(() => ({
export default function Tags(props) {
const classes = useStyles();
const { tags } = props;
const { tags, repoName, onTagDelete } = props;
const [tagsFilter, setTagsFilter] = useState('');
const [sortFilter, setSortFilter] = useState(tagsSortByCriteria.updateTimeDesc.value);
@ -63,6 +63,9 @@ export default function Tags(props) {
lastUpdated={tag.lastUpdated}
vendor={tag.vendor}
manifests={tag.manifests}
repo={repoName}
onTagDelete={onTagDelete}
isDeletable={tag.isDeletable}
/>
);
})

View File

@ -0,0 +1,54 @@
import React, { useState } from 'react';
import { IconButton } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
// utility
import { api, endpoints } from '../../api';
// components
import DeleteTagConfirmDialog from 'components/Shared/DeleteTagConfirmDialog';
import { host } from '../../host';
export default function DeleteTag(props) {
const { repo, tag, onTagDelete } = props;
const [open, setOpen] = useState(false);
const handleClickOpen = () => {
setOpen(true);
};
const handleClose = () => {
setOpen(false);
};
const deleteTag = (repo, tag) => {
api
.delete(`${host()}${endpoints.deleteImage(repo, tag)}`)
.then((response) => {
if (response && response.status == 202) {
onTagDelete(tag);
}
})
.catch((err) => {
console.error(err);
});
};
const onConfirm = () => {
deleteTag(repo, tag);
};
return (
<React.Fragment>
<IconButton onClick={handleClickOpen}>
<DeleteIcon />
</IconButton>
<DeleteTagConfirmDialog
onClose={handleClose}
open={open}
title={`Permanently delete image ${repo}:${tag}?`}
onConfirm={onConfirm}
/>
</React.Fragment>
);
}

View File

@ -0,0 +1,30 @@
import React from 'react';
// components
import { Button, Dialog, DialogTitle, DialogActions } from '@mui/material';
export default function DeleteTagConfirmDialog(props) {
const { onClose, open, title, onConfirm } = props;
return (
<Dialog data-testid="delete-dialog" onClose={onClose} open={open} color="primary">
<DialogTitle> {title} </DialogTitle>
<DialogActions style={{ justifyContent: 'center' }}>
<Button data-testid="cancel-delete" variant="contained" onClick={onClose} color="primary">
Cancel
</Button>
<Button
data-testid="confirm-delete"
color="error"
variant="contained"
onClick={() => {
onConfirm();
onClose();
}}
>
Delete
</Button>
</DialogActions>
</Dialog>
);
}

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

@ -0,0 +1,40 @@
// react global
import React from 'react';
// components
import { Stack, Typography } from '@mui/material';
//styling
import makeStyles from '@mui/styles/makeStyles';
import nodataImage from '../../assets/noData.svg';
const useStyles = makeStyles((theme) => ({
noDataContainer: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
},
noDataImage: {
maxWidth: '233px',
maxHeight: '240px'
},
noDataText: {
fontSize: '1.5rem',
fontWeight: '600',
color: theme.palette.secondary.main
}
}));
function NoDataComponent({ text }) {
const classes = useStyles();
return (
<Stack className={classes.noDataContainer}>
<img src={nodataImage} className={classes.noDataImage} />
<Typography className={classes.noDataText}>{text ? text : 'No Data'}</Typography>
</Stack>
);
}
export default NoDataComponent;

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';
@ -28,17 +28,20 @@ import {
import makeStyles from '@mui/styles/makeStyles';
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 { 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);
@ -183,16 +186,23 @@ function RepoCard(props) {
platforms,
description,
downloads,
isSigned,
stars,
signatureInfo,
lastUpdated,
version,
vulnerabilityData,
isBookmarked
isBookmarked,
isStarred
} = props;
// keep a local bookmark state to display in the ui dynamically on updates
const [currentBookmarkValue, setCurrentBookmarkValue] = useState(isBookmarked);
// keep a local star state to display in the ui dynamically on updates
const [currentStarValue, setCurrentStarValue] = useState(isStarred);
const [currentStarCount, setCurrentStarCount] = useState(stars);
const goToDetails = () => {
navigate(`/image/${encodeURIComponent(name)}`);
};
@ -214,6 +224,23 @@ function RepoCard(props) {
});
};
const handleStarClick = (event) => {
event.stopPropagation();
event.preventDefault();
api.put(`${host()}${endpoints.starToggle(name)}`, abortController.signal).then((response) => {
if (response.status === 200) {
setCurrentStarValue((prevState) => !prevState);
currentStarValue
? setCurrentStarCount((prevState) => {
return !isNaN(prevState) ? prevState - 1 : prevState;
})
: setCurrentStarCount((prevState) => {
return !isNaN(prevState) ? prevState + 1 : prevState;
});
}
});
};
const platformChips = () => {
const filteredPlatforms = uniq(platforms?.flatMap((platform) => [platform.Os, platform.Arch]));
const hiddenChips = filteredPlatforms.length - MAX_PLATFORM_CHIPS;
@ -259,6 +286,34 @@ function RepoCard(props) {
);
};
const renderStar = () => {
return (
isAuthenticated() && (
<IconButton component="span" onClick={handleStarClick} data-testid="star-button">
{currentStarValue ? <StarIcon data-testid="starred" /> : <StarBorderIcon data-testid="not-starred" />}
</IconButton>
)
);
};
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
@ -286,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} 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>
@ -336,6 +389,15 @@ function RepoCard(props) {
#1
</Typography>
</Grid> */}
<Grid item xs={12}>
{renderStar()}
<Typography variant="body2" component="span" className={classes.contentRightLabel}>
Stars
</Typography>
<Typography variant="body2" component="span" className={classes.contentRightValue}>
{!isNaN(currentStarCount) ? currentStarCount : `not available`}
</Typography>
</Grid>
<Grid container item xs={12} className={classes.contentRightActions}>
<Grid item>{renderBookmark()}</Grid>
</Grid>

View File

@ -0,0 +1,19 @@
import React, { useMemo } from 'react';
import { Typography, Stack } from '@mui/material';
import { isEmpty } from 'lodash';
import { getStrongestSignature, getAllAuthorsOfSignatures } from 'utilities/vulnerabilityAndSignatureCheck';
function SignatureTooltip({ signatureInfo }) {
const strongestSignature = useMemo(() => getStrongestSignature(signatureInfo));
return isEmpty(strongestSignature) ? (
<Typography>Not signed</Typography>
) : (
<Stack direction="column">
<Typography>Tool: {strongestSignature?.tool || 'Unknown'}</Typography>
<Typography>Signed-by: {getAllAuthorsOfSignatures(signatureInfo) || 'Unknown'}</Typography>
</Stack>
);
}
export default SignatureTooltip;

View File

@ -1,11 +1,12 @@
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';
import { DateTime } from 'luxon';
import { KeyboardArrowDown, KeyboardArrowRight } from '@mui/icons-material';
import DeleteTag from 'components/Shared/DeleteTag';
const useStyles = makeStyles((theme) => ({
card: {
@ -78,9 +79,9 @@ const useStyles = makeStyles((theme) => ({
}));
export default function TagCard(props) {
const { repoName, tag, lastUpdated, vendor, manifests } = props;
const { repoName, tag, lastUpdated, vendor, manifests, repo, onTagDelete, isDeletable } = props;
const [open, setOpen] = useState(false);
const classes = useStyles();
const lastDate = lastUpdated
@ -99,9 +100,12 @@ export default function TagCard(props) {
return (
<Card className={classes.card} raised>
<CardContent className={classes.content}>
<Typography variant="body1" align="left" className={classes.tagHeading}>
Tag
</Typography>
<Stack direction="row" spacing={2} justifyContent="space-between">
<Typography variant="body1" align="left" className={classes.tagHeading}>
Tag
</Typography>
{isDeletable && <DeleteTag repo={repo} tag={tag} onTagDelete={onTagDelete} />}
</Stack>
<Typography variant="body1" align="left" className={classes.tagName} onClick={() => goToTags()}>
{repoName && `${repoName}:`}
{tag}
@ -163,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',
@ -66,15 +102,21 @@ const useStyles = makeStyles((theme) => ({
cursor: 'pointer',
textAlign: 'center'
},
dropdownCVE: {
color: '#1479FF',
cursor: 'pointer'
},
vulnerabilityCardDivider: {
margin: '1rem 0'
},
cveInfo: {
marginTop: '2%'
}
}));
function VulnerabilitiyCard(props) {
const classes = useStyles();
const { cve, name } = props;
const [openDesc, setOpenDesc] = useState(false);
const [openFixed, setOpenFixed] = useState(false);
const { cve, name, platform, expand } = props;
const [openCVE, setOpenCVE] = useState(expand);
const [loadingFixed, setLoadingFixed] = useState(true);
const [fixedInfo, setFixedInfo] = useState([]);
const abortController = useMemo(() => new AbortController(), []);
@ -82,15 +124,21 @@ 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);
api
.get(
`${host()}${endpoints.imageListWithCVEFixed(cve.id, name, { pageNumber, pageSize: CVE_FIXEDIN_PAGE_SIZE })}`,
`${host()}${endpoints.imageListWithCVEFixed(
cve.id,
name,
{ pageNumber, pageSize: CVE_FIXEDIN_PAGE_SIZE },
platform ? { Os: platform.Os, Arch: platform.Arch } : {}
)}`,
abortController.signal
)
.then((response) => {
@ -102,11 +150,13 @@ function VulnerabilitiyCard(props) {
);
}
setLoadingFixed(false);
setLoadMoreInfo(false);
})
.catch((e) => {
console.error(e);
setIsEndOfList(true);
setLoadingFixed(false);
setLoadMoreInfo(false);
});
};
@ -115,10 +165,15 @@ function VulnerabilitiyCard(props) {
return () => {
abortController.abort();
};
}, [openFixed, pageNumber]);
}, [openCVE, pageNumber, loadMoreInfo]);
useEffect(() => {
setOpenCVE(expand);
}, [expand]);
const loadMore = () => {
if (loadingFixed || isEndOfList) return;
setLoadMoreInfo(true);
setPageNumber((pageNumber) => pageNumber + 1);
};
@ -158,27 +213,65 @@ function VulnerabilitiyCard(props) {
};
return (
<Card className={classes.card} raised>
<CardContent className={classes.content}>
<Stack direction="row" spacing="1.25rem">
<Typography variant="body1" align="left" className={classes.cveId}>
<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={openCVE ? classes.cveId : classes.cveIdCollapsed}>
{cve.id}
</Typography>
<VulnerabilityChipCheck vulnerabilitySeverity={cve.severity} />
</Stack>
<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} />
{openCVE ? (
<VulnerabilityChipCheck vulnerabilitySeverity={cve.severity} />
) : (
<KeyboardArrowDown className={classes.dropdownText} />
<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>
)}
<Typography className={classes.dropdownText}>Fixed in</Typography>
</Stack>
<Collapse in={openFixed} timeout="auto" unmountOnExit>
<Collapse in={openCVE} timeout="auto" unmountOnExit>
<Typography variant="body1" align="left" className={classes.cveSummary}>
{cve.title}
</Typography>
<Divider className={classes.vulnerabilityCardDivider} />
<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>
<Typography variant="body2" align="left" className={classes.cveInfo}>
Fixed in
</Typography>
<Box sx={{ width: '100%', padding: '0.5rem 0' }}>
{loadingFixed ? (
'Loading...'
@ -189,16 +282,9 @@ function VulnerabilitiyCard(props) {
</Stack>
)}
</Box>
</Collapse>
<Stack className={classes.dropdown} onClick={() => setOpenDesc(!openDesc)}>
{!openDesc ? (
<KeyboardArrowRight className={classes.dropdownText} />
) : (
<KeyboardArrowDown className={classes.dropdownText} />
)}
<Typography className={classes.dropdownText}>Description</Typography>
</Stack>
<Collapse in={openDesc} timeout="auto" unmountOnExit>
<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}

View File

@ -0,0 +1,100 @@
import React from 'react';
import makeStyles from '@mui/styles/makeStyles';
import { Stack, Tooltip } from '@mui/material';
const criticalColor = '#ff5c74';
const criticalBorderColor = '#f9546d';
const highColor = '#ff6840';
const highBorderColor = '#ee6b49';
const mediumColor = '#ffa052';
const mediumBorderColor = '#f19d5b';
const lowColor = '#f9f486';
const lowBorderColor = '#f0ed94';
const unknownColor = '#f2ffdd';
const unknownBorderColor = '#e9f4d7';
const totalBorderColor = '#e0e5eb';
const fontSize = '0.75rem';
const useStyles = makeStyles((theme) => ({
cveCountCard: {
display: 'flex',
alignItems: 'center',
paddingLeft: '0.5rem',
paddingRight: '0.5rem',
color: theme.palette.primary.main,
fontSize: fontSize,
fontWeight: '600',
borderRadius: '3px',
marginBottom: '0',
cursor: 'pointer'
},
totalSeverity: {
border: '1px solid ' + totalBorderColor
},
severityList: {
fontSize: fontSize,
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: '0.5em'
},
criticalSeverity: {
backgroundColor: criticalColor,
border: '1px solid ' + criticalBorderColor
},
highSeverity: {
backgroundColor: highColor,
border: '1px solid ' + highBorderColor
},
mediumSeverity: {
backgroundColor: mediumColor,
border: '1px solid ' + mediumBorderColor
},
lowSeverity: {
backgroundColor: lowColor,
border: '1px solid ' + lowBorderColor
},
unknownSeverity: {
backgroundColor: unknownColor,
border: '1px solid ' + unknownBorderColor
}
}));
function VulnerabilitiyCountCard(props) {
const classes = useStyles();
const { total, critical, high, medium, low, unknown, filterBySeverity } = props;
return (
<Stack direction="row" spacing="0.5em">
<Tooltip title="Total" onClick={() => filterBySeverity('')}>
<div className={[classes.cveCountCard, classes.totalSeverity].join(' ')}>Total {total}</div>
</Tooltip>
<div className={classes.severityList}>
<Tooltip title="Critical" onClick={() => filterBySeverity('CRITICAL')}>
<div className={[classes.cveCountCard, classes.criticalSeverity].join(' ')}>C {critical}</div>
</Tooltip>
<Tooltip title="High" onClick={() => filterBySeverity('HIGH')}>
<div className={[classes.cveCountCard, classes.highSeverity].join(' ')}>H {high}</div>
</Tooltip>
<Tooltip title="Medium" onClick={() => filterBySeverity('MEDIUM')}>
<div className={[classes.cveCountCard, classes.mediumSeverity].join(' ')}>M {medium}</div>
</Tooltip>
<Tooltip title="Low" onClick={() => filterBySeverity('LOW')}>
<div className={[classes.cveCountCard, classes.lowSeverity].join(' ')}>L {low}</div>
</Tooltip>
<Tooltip title="Unknown" onClick={() => filterBySeverity('UNKNOWN')}>
<div className={[classes.cveCountCard, classes.unknownSeverity].join(' ')}>U {unknown}</div>
</Tooltip>
</div>
</Stack>
);
}
export default VulnerabilitiyCountCard;

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

@ -4,24 +4,55 @@ import React, { useEffect, useMemo, useState, useRef } from 'react';
import { api, endpoints } from '../../../api';
// components
import { Stack, Typography, InputBase } from '@mui/material';
import {
IconButton,
Stack,
Typography,
InputBase,
ToggleButton,
Menu,
MenuItem,
Divider,
Snackbar,
CircularProgress
} from '@mui/material';
import makeStyles from '@mui/styles/makeStyles';
import { host } from '../../../host';
import { debounce, isEmpty } from 'lodash';
import Loading from '../../Shared/Loading';
import { mapCVEInfo } from 'utilities/objectModels';
import { mapCVEInfo, mapAllCVEInfo } from 'utilities/objectModels';
import { EXPLORE_PAGE_SIZE } from 'utilities/paginationConstants';
import SearchIcon from '@mui/icons-material/Search';
import DownloadIcon from '@mui/icons-material/Download';
import * as XLSX from 'xlsx';
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';
const useStyles = makeStyles((theme) => ({
searchAndDisplayBar: {
display: 'flex',
justifyContent: 'space-between'
},
title: {
color: theme.palette.primary.main,
fontSize: '1.5rem',
fontWeight: '600',
marginBottom: '0'
},
cveCountSummary: {
color: theme.palette.primary.main,
fontSize: '1.5rem',
fontWeight: '600',
marginBottom: '0'
},
cveId: {
color: theme.palette.primary.main,
fontSize: '1rem',
@ -40,9 +71,17 @@ const useStyles = makeStyles((theme) => ({
fontSize: '1.4rem',
fontWeight: '600'
},
vulnerabilities: {
position: 'relative',
maxWidth: '100%',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between'
},
search: {
position: 'relative',
maxWidth: '100%',
flex: 0.95,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
@ -50,6 +89,20 @@ const useStyles = makeStyles((theme) => ({
border: '0.063rem solid #E7E7E7',
borderRadius: '0.625rem'
},
expandableSearchInput: {
flexGrow: 0.95
},
view: {
alignContent: 'right',
variant: 'outlined'
},
viewModes: {
position: 'relative',
alignItems: 'baseline',
maxWidth: '100%',
flexDirection: 'row',
justifyContent: 'right'
},
searchIcon: {
color: '#52637A',
paddingRight: '3%'
@ -65,38 +118,92 @@ const useStyles = makeStyles((theme) => ({
'&::placeholder': {
opacity: '1'
}
},
popper: {
width: '100%',
overflow: 'hidden',
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%'
}
}));
function VulnerabilitiesDetails(props) {
const classes = useStyles();
const [cveData, setCveData] = useState([]);
const [allCveData, setAllCveData] = useState([]);
const [cveSummary, setCVESummary] = useState({});
const [isLoading, setIsLoading] = useState(true);
const [isLoadingAllCve, setIsLoadingAllCve] = useState(true);
const abortController = useMemo(() => new AbortController(), []);
const { name, tag } = props;
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);
const [anchorExport, setAnchorExport] = useState(null);
const openExport = Boolean(anchorExport);
const [selectedViewMore, setSelectedViewMore] = useState(false);
const getCVERequestName = () => {
return digest !== '' ? `${name}@${digest}` : `${name}:${tag}`;
};
const getPaginatedCVEs = () => {
api
.get(
`${host()}${endpoints.vulnerabilitiesForRepo(
`${name}:${tag}`,
getCVERequestName(),
{ pageNumber, pageSize: EXPLORE_PAGE_SIZE },
cveFilter
cveFilter,
cveExcludeFilter,
cveSeverityFilter
)}`,
abortController.signal
)
.then((response) => {
if (response.data && response.data.data) {
let cveInfo = response.data.data.CVEListForImage?.CVEList;
let summary = response.data.data.CVEListForImage?.Summary;
let cveListData = mapCVEInfo(cveInfo);
setCveData((previousState) => (pageNumber === 1 ? cveListData : [...previousState, ...cveListData]));
setIsEndOfList(response.data.data.CVEListForImage.Page?.ItemCount < EXPLORE_PAGE_SIZE);
setCVESummary((previousState) => {
if (isEmpty(summary)) {
return previousState;
}
return {
Count: summary.Count,
UnknownCount: summary.UnknownCount,
LowCount: summary.LowCount,
MediumCount: summary.MediumCount,
HighCount: summary.HighCount,
CriticalCount: summary.CriticalCount
};
});
} else if (response.data.errors) {
setIsEndOfList(true);
}
@ -106,10 +213,29 @@ function VulnerabilitiesDetails(props) {
console.error(e);
setIsLoading(false);
setCveData([]);
setCVESummary(() => {});
setIsEndOfList(true);
});
};
const getAllCVEs = () => {
api
.get(`${host()}${endpoints.allVulnerabilitiesForRepo(getCVERequestName())}`, abortController.signal)
.then((response) => {
if (response.data && response.data.data) {
const cveInfo = response.data.data.CVEListForImage?.CVEList;
const cveListData = mapAllCVEInfo(cveInfo);
setAllCveData(cveListData);
}
setIsLoadingAllCve(false);
})
.catch((e) => {
console.error(e);
setAllCveData([]);
setIsLoadingAllCve(false);
});
};
const resetPagination = () => {
setIsLoading(true);
setIsEndOfList(false);
@ -120,12 +246,50 @@ function VulnerabilitiesDetails(props) {
}
};
const handleOnExportExcel = () => {
const wb = XLSX.utils.book_new(),
ws = XLSX.utils.json_to_sheet(allCveData);
XLSX.utils.book_append_sheet(wb, ws, 'vulnerabilities');
XLSX.writeFile(wb, `${name}:${tag}-vulnerabilities.xlsx`);
handleCloseExport();
};
const handleOnExportCSV = () => {
const fileName = `${name}:${tag}-vulnerabilities`;
const exportType = exportFromJSON.types.csv;
exportFromJSON({ data: allCveData, fileName, exportType });
handleCloseExport();
};
const handleCveFilterChange = (e) => {
const { value } = e.target;
setCveFilter(value);
};
const handleClickExport = (event) => {
setAnchorExport(event.currentTarget);
};
const handleCloseExport = () => {
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();
@ -159,25 +323,52 @@ function VulnerabilitiesDetails(props) {
useEffect(() => {
if (isLoading) return;
resetPagination();
}, [cveFilter]);
}, [cveFilter, cveExcludeFilter, cveSeverityFilter]);
useEffect(() => {
return () => {
abortController.abort();
debouncedChangeHandler.cancel();
debouncedExcludeFilterChangeHandler.cancel();
};
}, []);
useEffect(() => {
if (openExport && isEmpty(allCveData)) {
getAllCVEs();
}
}, [openExport]);
const renderCVEs = () => {
return !isEmpty(cveData) ? (
cveData.map((cve, index) => {
return <VulnerabilitiyCard key={index} cve={cve} name={name} />;
return <VulnerabilitiyCard key={index} cve={cve} name={name} platform={platform} expand={selectedViewMore} />;
})
) : (
<div>{!isLoading && <Typography className={classes.none}> No Vulnerabilities </Typography>}</div>
);
};
const renderCVESummary = () => {
if (cveSummary === undefined) {
return;
}
return !isEmpty(cveSummary) ? (
<VulnerabilityCountCard
total={cveSummary.Count}
critical={cveSummary.CriticalCount}
high={cveSummary.HighCount}
medium={cveSummary.MediumCount}
low={cveSummary.LowCount}
unknown={cveSummary.UnknownCount}
filterBySeverity={setCveSeverityFilter}
/>
) : (
<></>
);
};
const renderListBottom = () => {
if (isLoading) {
return <Loading />;
@ -190,21 +381,112 @@ function VulnerabilitiesDetails(props) {
return (
<Stack direction="column" spacing="1rem" data-testid="vulnerability-container">
<Typography variant="h4" gutterBottom component="div" align="left" className={classes.title}>
Vulnerabilities
</Typography>
<Stack className={classes.search}>
<InputBase
placeholder={'Search'}
classes={{ root: classes.searchInputBase, input: classes.input }}
onChange={debouncedChangeHandler}
/>
<div className={classes.searchIcon}>
<SearchIcon />
</div>
<Stack className={classes.vulnerabilities}>
<Typography variant="h4" gutterBottom component="div" align="left" className={classes.title}>
Vulnerabilities
</Typography>
<Stack direction="row" spacing="1rem" className={classes.viewModes}>
<IconButton disableRipple onClick={handleClickExport}>
<DownloadIcon />
</IconButton>
<Snackbar
open={openExport && isLoadingAllCve}
message="Getting your data ready for export"
action={<CircularProgress size="2rem" sx={{ color: '#FFFFFF' }} />}
/>
<ToggleButton
value="viewLess"
title="Collapse list view"
size="small"
className={classes.view}
selected={!selectedViewMore}
onChange={() => setSelectedViewMore(false)}
>
<ViewHeadlineIcon />
</ToggleButton>
<ToggleButton
value="viewMore"
title="Expand list view"
size="small"
className={classes.view}
selected={selectedViewMore}
onChange={() => setSelectedViewMore(true)}
data-testid="expand-list-view-toggle"
>
<ViewAgendaIcon />
</ToggleButton>
</Stack>
<Menu
anchorEl={anchorExport}
open={openExport}
onClose={handleCloseExport}
data-testid="export-dropdown"
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center'
}}
>
<MenuItem
onClick={handleOnExportCSV}
disableRipple
disabled={isLoadingAllCve}
className={classes.popper}
data-testid="export-csv-menuItem"
>
csv
</MenuItem>
<Divider sx={{ my: 0.5 }} />
<MenuItem
onClick={handleOnExportExcel}
disableRipple
disabled={isLoadingAllCve}
className={classes.popper}
data-testid="export-excel-menuItem"
>
xlsx
</MenuItem>
</Menu>
</Stack>
{renderCVESummary()}
<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: {
@ -59,7 +61,6 @@ const useStyles = makeStyles((theme) => ({
fontSize: '1rem',
lineHeight: '1.5rem',
color: '#52637A',
padding: '1rem 0 0 0',
maxWidth: '100%',
[theme.breakpoints.down('md')]: {
padding: '0.5rem 0 0 0',
@ -190,7 +191,7 @@ function TagDetails() {
}, [reponame, tag]);
const getPlatform = () => {
return selectedManifest.platform ? selectedManifest.platform : '--/--';
return selectedManifest?.platform ? selectedManifest.platform : '--/--';
};
const handleTabChange = (event, newValue) => {
@ -205,18 +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} />;
return (
<VulnerabilitiesDetails
name={reponame}
tag={tag}
digest={selectedManifest?.digest}
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 ? (
@ -227,10 +262,10 @@ function TagDetails() {
<Card className={classes.cardRoot}>
<CardContent className={classes.cardContent}>
<Grid container>
<Grid item xs={12} md={8} className={classes.header}>
<Grid item xs={12} md={9} className={classes.header}>
<Stack
alignItems="center"
sx={{ width: { xs: '100%', md: 'auto' } }}
sx={{ width: { xs: '100%', md: 'auto' }, marginBottom: '1rem' }}
direction={{ xs: 'column', md: 'row' }}
spacing={1}
>
@ -249,15 +284,16 @@ 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}
vulnerabilitySeverity={imageDetailData?.vulnerabiltySeverity}
count={imageDetailData?.vulnerabilityCount}
/>
<SignatureIconCheck isSigned={imageDetailData.isSigned} />
{getSignatureChips()}
</Stack>
<Stack sx={{ width: { xs: '100%', md: 'auto' } }}>
</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) && (
@ -267,19 +303,19 @@ function TagDetails() {
onChange={handleOSArchChange}
MenuProps={{ disableScrollLock: true }}
>
{imageDetailData.manifests.map((el) => (
{imageDetailData?.manifests?.map((el) => (
<MenuItem key={el.digest} value={el}>
{`${el.platform?.Os}/${el.platform?.Arch}`}
{`${el.platform?.Os || '----'}/${el.platform?.Arch || '----'}`}
</MenuItem>
))}
</Select>
)}
</FormControl>
<Typography gutterBottom className={classes.digest}>
Digest: {selectedManifest?.digest}
</Typography>
</Stack>
</Stack>
<Typography gutterBottom className={classes.digest}>
Digest: {selectedManifest?.digest}
</Typography>
)}
</Grid>
</Grid>
</CardContent>
@ -317,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

@ -6,6 +6,10 @@ const osFilters = [
{
label: 'linux',
value: 'linux'
},
{
label: 'freebsd',
value: 'freebsd'
}
];
@ -19,6 +23,11 @@ const imageFilters = [
label: 'Bookmarks',
value: 'IsBookmarked',
type: 'boolean'
},
{
label: 'Starred Repositories',
value: 'IsStarred',
type: 'boolean'
}
];
@ -57,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

@ -5,6 +5,7 @@ const mapToRepo = (responseRepo) => {
tags: responseRepo.NewestImage?.Labels,
description: responseRepo.NewestImage?.Description,
isSigned: responseRepo.NewestImage?.IsSigned,
signatureInfo: responseRepo.NewestImage?.SignatureInfo?.map((sigInfo) => mapSignatureInfo(sigInfo)),
isBookmarked: responseRepo.IsBookmarked,
isStarred: responseRepo.IsStarred,
platforms: responseRepo.Platforms,
@ -14,6 +15,7 @@ const mapToRepo = (responseRepo) => {
logo: responseRepo.NewestImage?.Logo,
lastUpdated: responseRepo.LastUpdated,
downloads: responseRepo.DownloadCount,
stars: responseRepo.StarCount,
vulnerabiltySeverity: responseRepo.NewestImage?.Vulnerabilities?.MaxSeverity,
vulnerabilityCount: responseRepo.NewestImage?.Vulnerabilities?.Count
};
@ -32,11 +34,13 @@ const mapToRepoFromRepoInfo = (responseRepoInfo) => {
title: responseRepoInfo.Summary?.NewestImage?.Title,
source: responseRepoInfo.Summary?.NewestImage?.Source,
downloads: responseRepoInfo.Summary?.NewestImage?.DownloadCount,
stars: responseRepoInfo.Summary?.NewestImage?.StarCount,
overview: responseRepoInfo.Summary?.NewestImage?.Documentation,
license: responseRepoInfo.Summary?.NewestImage?.Licenses,
vulnerabilitySeverity: responseRepoInfo.Summary?.NewestImage?.Vulnerabilities?.MaxSeverity,
vulnerabilityCount: responseRepoInfo.Summary?.NewestImage?.Vulnerabilities?.Count,
isSigned: responseRepoInfo.Summary?.NewestImage?.IsSigned,
signatureInfo: responseRepoInfo.Summary?.NewestImage?.SignatureInfo?.map((sigInfo) => mapSignatureInfo(sigInfo)),
isBookmarked: responseRepoInfo.Summary?.IsBookmarked,
isStarred: responseRepoInfo.Summary?.IsStarred,
logo: responseRepoInfo.Summary?.NewestImage?.Logo
@ -51,9 +55,11 @@ const mapToImage = (responseImage) => {
referrers: responseImage.Referrers,
size: responseImage.Size,
downloadCount: responseImage.DownloadCount,
starCount: responseImage.StarCount,
lastUpdated: responseImage.LastUpdated,
description: responseImage.Description,
isSigned: responseImage.IsSigned,
signatureInfo: responseImage.SignatureInfo?.map((sigInfo) => mapSignatureInfo(sigInfo)),
license: responseImage.Licenses,
labels: responseImage.Labels,
title: responseImage.Title,
@ -63,6 +69,7 @@ const mapToImage = (responseImage) => {
authors: responseImage.Authors,
vulnerabiltySeverity: responseImage.Vulnerabilities?.MaxSeverity,
vulnerabilityCount: responseImage.Vulnerabilities?.Count,
isDeletable: responseImage.IsDeletable,
// frontend only prop to increase interop with Repo objects and code reusability
name: `${responseImage.RepoName}:${responseImage.Tag}`
};
@ -76,9 +83,11 @@ const mapToManifest = (responseManifest) => {
size: responseManifest.Size,
platform: responseManifest.Platform,
downloadCount: responseManifest.DownloadCount,
starCount: responseManifest.StarCount,
layers: responseManifest.Layers,
history: responseManifest.History,
vulnerabilities: responseManifest.Vulnerabilities
vulnerabilities: responseManifest.Vulnerabilities,
referrers: responseManifest.Referrers
};
};
@ -88,12 +97,52 @@ 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;
};
const mapAllCVEInfo = (cveInfo) => {
const cveList = cveInfo.flatMap((cve) => {
return cve.PackageList.map((packageInfo) => {
return {
id: cve.Id,
severity: cve.Severity,
title: cve.Title,
description: cve.Description,
reference: cve.Reference,
packageName: packageInfo.Name,
packagePath: packageInfo.PackagePath,
packageInstalledVersion: packageInfo.InstalledVersion,
packageFixedVersion: packageInfo.FixedVersion
};
});
});
return cveList;
};
const mapSignatureInfo = (signatureInfo) => {
return signatureInfo
? {
tool: signatureInfo.Tool,
isTrusted: signatureInfo.IsTrusted,
author: signatureInfo.Author
}
: {
tool: 'Unknown',
isTrusted: false,
author: 'Unknown'
};
};
const mapReferrer = (referrer) => ({
mediaType: referrer.MediaType,
artifactType: referrer.ArtifactType,
@ -102,4 +151,4 @@ const mapReferrer = (referrer) => ({
annotations: referrer.Annotations?.map((annotation) => ({ key: annotation.Key, value: annotation.Value }))
});
export { mapToRepo, mapToImage, mapToRepoFromRepoInfo, mapCVEInfo, mapReferrer, mapToManifest };
export { mapToRepo, mapToImage, mapToRepoFromRepoInfo, mapCVEInfo, mapAllCVEInfo, mapReferrer, mapToManifest };

View File

@ -4,6 +4,7 @@ const HOME_PAGE_SIZE = 10;
const HOME_POPULAR_PAGE_SIZE = 3;
const HOME_RECENT_PAGE_SIZE = 2;
const HOME_BOOKMARKS_PAGE_SIZE = 2;
const HOME_STARS_PAGE_SIZE = 2;
const CVE_FIXEDIN_PAGE_SIZE = 5;
export {
@ -13,5 +14,6 @@ export {
CVE_FIXEDIN_PAGE_SIZE,
HOME_POPULAR_PAGE_SIZE,
HOME_RECENT_PAGE_SIZE,
HOME_BOOKMARKS_PAGE_SIZE
HOME_BOOKMARKS_PAGE_SIZE,
HOME_STARS_PAGE_SIZE
};

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 }) => {
if (isSigned) {
return <VerifiedSignatureIcon />;
} else {
return <UnverifiedSignatureIcon />;
}
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,8 +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" />,
@ -13,17 +15,21 @@ 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'
);
const NoneVulnerabilityIcon = ({ vulnerabilityStringTitle }) => {
return (
<Tooltip title={`${vulnerabilityStringTitle} Vulnerability`} placement="top">
<Tooltip title={`${vulnerabilityStringTitle}`} placement="top">
<OutlinedBugIcon
sx={{
color: '#43A047!important',
@ -40,7 +46,7 @@ const NoneVulnerabilityIcon = ({ vulnerabilityStringTitle }) => {
};
const UnknownVulnerabilityIcon = ({ vulnerabilityStringTitle }) => {
return (
<Tooltip title={`${vulnerabilityStringTitle} Vulnerability`} placement="top">
<Tooltip title={`${vulnerabilityStringTitle}`} placement="top">
<OutlinedBugIcon
sx={{
color: '#52637A',
@ -75,7 +81,7 @@ const FailedScanIcon = () => {
};
const LowVulnerabilityIcon = ({ vulnerabilityStringTitle }) => {
return (
<Tooltip title={`${vulnerabilityStringTitle} Vulnerability`} placement="top">
<Tooltip title={`${vulnerabilityStringTitle}`} placement="top">
<OutlinedBugIcon
sx={{
color: '#FB8C00',
@ -92,7 +98,7 @@ const LowVulnerabilityIcon = ({ vulnerabilityStringTitle }) => {
};
const MediumVulnerabilityIcon = ({ vulnerabilityStringTitle }) => {
return (
<Tooltip title={`${vulnerabilityStringTitle} Vulnerability`} placement="top">
<Tooltip title={`${vulnerabilityStringTitle}`} placement="top">
<FilledBugIcon
sx={{
color: '#FB8C00',
@ -109,7 +115,7 @@ const MediumVulnerabilityIcon = ({ vulnerabilityStringTitle }) => {
};
const HighVulnerabilityIcon = ({ vulnerabilityStringTitle }) => {
return (
<Tooltip title={`${vulnerabilityStringTitle} Vulnerability`} placement="top">
<Tooltip title={`${vulnerabilityStringTitle}`} placement="top">
<OutlinedBugIcon
sx={{
color: '#E53935',
@ -126,7 +132,7 @@ const HighVulnerabilityIcon = ({ vulnerabilityStringTitle }) => {
};
const CriticalVulnerabilityIcon = ({ vulnerabilityStringTitle }) => {
return (
<Tooltip title={`${vulnerabilityStringTitle} Vulnerability`} placement="top">
<Tooltip title={`${vulnerabilityStringTitle}`} placement="top">
<FilledBugIcon
sx={{
color: '#E53935',
@ -144,13 +150,10 @@ const CriticalVulnerabilityIcon = ({ vulnerabilityStringTitle }) => {
const NoneVulnerabilityChip = () => {
return (
<Chip
label="No Vulnerability"
label="None"
sx={{ backgroundColor: '#E8F5E9', color: '#388E3C', fontSize: '0.8125rem' }}
variant="filled"
onDelete={() => {
return;
}}
deleteIcon={<OutlinedBugIcon sx={{ color: '#388E3C!important' }} />}
icon={<OutlinedBugIcon sx={{ color: '#388E3C!important' }} />}
data-testid="none-vulnerability-chip"
/>
);
@ -158,13 +161,10 @@ const NoneVulnerabilityChip = () => {
const UnknownVulnerabilityChip = () => {
return (
<Chip
label="Unknown Vulnerability"
label="Unknown"
sx={{ backgroundColor: '#ECEFF1', color: '#52637A', fontSize: '0.8125rem' }}
variant="filled"
onDelete={() => {
return;
}}
deleteIcon={<OutlinedBugIcon sx={{ color: '#52637A!important' }} />}
icon={<OutlinedBugIcon sx={{ color: '#52637A!important' }} />}
data-testid="unknown-vulnerability-chip"
/>
);
@ -175,10 +175,7 @@ const FailedScanChip = () => {
label="Failed to scan"
sx={{ backgroundColor: '#848484', color: '#F6F7F9', fontSize: '0.8125rem' }}
variant="filled"
onDelete={() => {
return;
}}
deleteIcon={<SvgIcon component={failedScanBug} sx={{ color: '#F6F7F9!important' }} />}
icon={<SvgIcon component={failedScanBug} sx={{ color: '#F6F7F9!important' }} />}
data-testid="failed-vulnerability-chip"
/>
);
@ -186,13 +183,10 @@ const FailedScanChip = () => {
const LowVulnerabilityChip = () => {
return (
<Chip
label="Low Vulnerability"
label="Low"
sx={{ backgroundColor: '#FFF3E0', color: '#FB8C00', fontSize: '0.8125rem' }}
variant="filled"
onDelete={() => {
return;
}}
deleteIcon={<OutlinedBugIcon sx={{ color: '#FB8C00!important' }} />}
icon={<OutlinedBugIcon sx={{ color: '#FB8C00!important' }} />}
data-testid="low-vulnerability-chip"
/>
);
@ -200,13 +194,10 @@ const LowVulnerabilityChip = () => {
const MediumVulnerabilityChip = () => {
return (
<Chip
label="Medium Vulnerability"
label="Medium"
sx={{ backgroundColor: '#FFF3E0', color: '#FB8C00', fontSize: '0.8125rem' }}
variant="filled"
onDelete={() => {
return;
}}
deleteIcon={<FilledBugIcon sx={{ color: '#FB8C00!important' }} />}
icon={<FilledBugIcon sx={{ color: '#FB8C00!important' }} />}
data-testid="medium-vulnerability-chip"
/>
);
@ -214,13 +205,10 @@ const MediumVulnerabilityChip = () => {
const HighVulnerabilityChip = () => {
return (
<Chip
label="High Vulnerability"
label="High"
sx={{ backgroundColor: '#FEEBEE', color: '#E53935', fontSize: '0.8125rem' }}
variant="filled"
onDelete={() => {
return;
}}
deleteIcon={<OutlinedBugIcon sx={{ color: '#E53935!important' }} />}
icon={<OutlinedBugIcon sx={{ color: '#E53935!important' }} />}
data-testid="high-vulnerability-chip"
/>
);
@ -228,22 +216,20 @@ const HighVulnerabilityChip = () => {
const CriticalVulnerabilityChip = () => {
return (
<Chip
label="Critical Vulnerability"
label="Critical"
sx={{ backgroundColor: '#FEEBEE', color: '#E53935', fontSize: '0.8125rem' }}
variant="filled"
onDelete={() => {
return;
}}
deleteIcon={<FilledBugIcon sx={{ color: '#E53935!important' }} />}
icon={<FilledBugIcon sx={{ color: '#E53935!important' }} />}
data-testid="critical-vulnerability-chip"
/>
);
};
const UnverifiedSignatureIcon = () => {
const UnverifiedSignatureIcon = ({ signatureInfo }) => {
return (
<Tooltip title="Unverified Signature" placement="top">
<Tooltip title={<SignatureTooltip signatureInfo={signatureInfo} />} placement="top">
<UnverifiedShieldIcon
viewBox="0 0 18 22"
sx={{
color: '#E53935',
padding: '0.2rem',
@ -257,22 +243,119 @@ const UnverifiedSignatureIcon = () => {
</Tooltip>
);
};
const VerifiedSignatureIcon = () => {
const NotTrustedSignatureIcon = ({ signatureInfo }) => {
return (
<Tooltip title="Verified Signature" 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="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 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>
);
};
@ -290,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,
@ -318,9 +388,9 @@ export {
HighVulnerabilityChip,
CriticalVulnerabilityChip,
UnverifiedSignatureIcon,
NotTrustedSignatureIcon,
VerifiedSignatureIcon,
UnverifiedSignatureChip,
VerifiedSignatureChip,
FailedScanIcon,
FailedScanChip
};

View File

@ -7,7 +7,7 @@ import { hosts, endpoints, sortCriteria } from './values/test-constants';
test.describe('explore page test', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem('token', '-');
window.localStorage.setItem('authConfig', '{}');
});
});
@ -76,8 +76,14 @@ test.describe('explore page test', () => {
await expect(exploreFirst).toBeVisible({ timeout: 250000 });
const windowsFilter = page.getByRole('checkbox', { name: 'windows' });
await linuxFilter.uncheck();
await page.getByRole('checkbox', { name: 'windows' }).check();
await windowsFilter.check();
await expect(exploreFirst).not.toBeVisible({ timeout: 250000 });
const freebsdFilter = page.getByRole('checkbox', { name: 'freebsd' });
await windowsFilter.uncheck();
await freebsdFilter.check();
await expect(exploreFirst).not.toBeVisible({ timeout: 250000 });
});
});

View File

@ -5,7 +5,7 @@ import { hosts, endpoints, sortCriteria } from './values/test-constants';
test.describe('homepage test', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem('token', '-');
window.localStorage.setItem('authConfig', '{}');
});
});

View File

@ -5,7 +5,7 @@ import { getRepoListOrderedAlpha } from './utils/test-data-parser';
test.describe('navbar test', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem('token', '-');
window.localStorage.setItem('authConfig', '{}');
});
});

View File

@ -8,7 +8,7 @@ const testRepo = getMultiTagRepo();
test.describe('Repository page test', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem('token', '-');
window.localStorage.setItem('authConfig', '{}');
});
await page.goto(`${hosts.ui}/image/${testRepo.repo}`);

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

@ -5,7 +5,7 @@ import { hosts, pageSizes } from './values/test-constants';
test.describe('Tag page test', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem('token', '-');
window.localStorage.setItem('authConfig', '{}');
});
});
@ -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,7 +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(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

@ -17,15 +17,15 @@ const pageSizes = {
};
const endpoints = {
repoList: `/v2/_zot/ext/search?query={RepoListWithNewestImage(requestedPage:%20{limit:15%20offset:0}){Results%20{Name%20LastUpdated%20Size%20Platforms%20{Os%20Arch}%20%20NewestImage%20{%20Tag%20Vulnerabilities%20{MaxSeverity%20Count}%20Description%20%20Licenses%20Logo%20Title%20Source%20IsSigned%20Documentation%20Vendor%20Labels}%20DownloadCount}}}`,
repoList: `/v2/_zot/ext/search?query={RepoListWithNewestImage(requestedPage:%20{limit:15%20offset:0}){Results%20{Name%20LastUpdated%20Size%20Platforms%20{Os%20Arch}%20%20NewestImage%20{%20Tag%20Vulnerabilities%20{MaxSeverity%20Count}%20Description%20%20Licenses%20Logo%20Title%20Source%20IsSigned%20Documentation%20Vendor%20Labels}%20StarCount%20DownloadCount}}}`,
detailedRepoInfo: (name) =>
`/v2/_zot/ext/search?query={ExpandedRepoInfo(repo:%22${name}%22){Images%20{Manifests%20{Digest%20Platform%20{Os%20Arch}%20Size}%20Vulnerabilities%20{MaxSeverity%20Count}%20Tag%20LastUpdated%20Vendor%20}%20Summary%20{Name%20LastUpdated%20Size%20Platforms%20{Os%20Arch}%20Vendors%20NewestImage%20{RepoName%20IsSigned%20Vulnerabilities%20{MaxSeverity%20Count}%20Manifests%20{Digest}%20Tag%20Title%20Documentation%20DownloadCount%20Source%20Description%20Licenses}}}}`,
`/v2/_zot/ext/search?query={ExpandedRepoInfo(repo:%22${name}%22){Images%20{Manifests%20{Digest%20Platform%20{Os%20Arch}%20Size}%20Vulnerabilities%20{MaxSeverity%20Count}%20Tag%20LastUpdated%20Vendor%20IsDeletable%20}%20Summary%20{Name%20LastUpdated%20Size%20Platforms%20{Os%20Arch}%20Vendors%20NewestImage%20{RepoName%20IsSigned%20Vulnerabilities%20{MaxSeverity%20Count}%20Manifests%20{Digest}%20Tag%20Title%20Documentation%20DownloadCount%20Source%20Description%20Licenses}}}}`,
globalSearch: (searchTerm, sortCriteria, pageNumber = 1, pageSize = 10) =>
`/v2/_zot/ext/search?query={GlobalSearch(query:%22${searchTerm}%22,%20requestedPage:%20{limit:${pageSize}%20offset:${
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%20Licenses%20Vendor%20Labels%20}%20DownloadCount}}}`,
}%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%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 };