26 Commits

Author SHA1 Message Date
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
51 changed files with 2494 additions and 323 deletions

View File

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

View File

@ -23,6 +23,14 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: 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 - name: Checkout zui repository
uses: actions/checkout@v3 uses: actions/checkout@v3
with: with:
@ -73,7 +81,7 @@ jobs:
- name: Install go - name: Install go
uses: actions/setup-go@v3 uses: actions/setup-go@v3
with: with:
go-version: 1.20.x go-version: 1.21.x
- name: Checkout zot repo - name: Checkout zot repo
uses: actions/checkout@v3 uses: actions/checkout@v3
@ -86,7 +94,7 @@ jobs:
- name: Build zot - name: Build zot
run: | run: |
cd $GITHUB_WORKSPACE/zot cd $GITHUB_WORKSPACE/zot
make binary make binary ZUI_BUILD_PATH=$GITHUB_WORKSPACE/build
ls -l bin/ ls -l bin/
- name: Bringup zot server - name: Bringup zot server
@ -116,10 +124,10 @@ jobs:
cd $GITHUB_WORKSPACE cd $GITHUB_WORKSPACE
make playwright-browsers make playwright-browsers
- name: Trigger CVE scanning
- name: Trigger catalog
run: | run: |
while true; do x=0; curl -f http://$REGISTRY_HOST:$REGISTRY_PORT/v2/_catalog || x=1; if [ $x -eq 0 ]; then break; fi; sleep 1; done # 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 - name: Run integration tests
run: | run: |

313
package-lock.json generated
View File

@ -14,19 +14,22 @@
"@mui/lab": "^5.0.0-alpha.89", "@mui/lab": "^5.0.0-alpha.89",
"@mui/material": "^5.8.6", "@mui/material": "^5.8.6",
"@mui/styles": "^5.8.6", "@mui/styles": "^5.8.6",
"@mui/x-date-pickers": "^6.18.4",
"@testing-library/jest-dom": "^5.16.1", "@testing-library/jest-dom": "^5.16.1",
"@testing-library/react": "^12.1.2", "@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"axios": "^0.24.0", "axios": "^0.24.0",
"downshift": "^6.1.12", "downshift": "^6.1.12",
"export-from-json": "^1.7.3",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"luxon": "^2.5.2", "luxon": "^3.4.4",
"markdown-to-jsx": "^7.1.7", "markdown-to-jsx": "^7.1.7",
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-router-dom": "^6.2.1", "react-router-dom": "^6.2.1",
"react-sticky-el": "^2.0.9", "react-sticky-el": "^2.0.9",
"web-vitals": "^2.1.3" "web-vitals": "^2.1.3",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.16.7", "@babel/plugin-proposal-private-property-in-object": "^7.16.7",
@ -2128,16 +2131,21 @@
"dev": true "dev": true
}, },
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
"version": "7.22.5", "version": "7.23.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.5.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.6.tgz",
"integrity": "sha512-ecjvYlnAaZ/KVneE/OdKYBYfgXV3Ptu6zQWmgEF7vwKhQnvVS6bjMD2XYgj+SNvQ1GfK/pjgokfPkC/2CO8CuA==", "integrity": "sha512-zHd0eUrf5GZoOWVCXp6koAKQTfZV07eit6bGPmJgnZdnSAvvZee6zniW2XMF7Cmc4ISOOnPy3QaSiIJGJkVEDQ==",
"dependencies": { "dependencies": {
"regenerator-runtime": "^0.13.11" "regenerator-runtime": "^0.14.0"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/runtime/node_modules/regenerator-runtime": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz",
"integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA=="
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.22.5", "version": "7.22.5",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.5.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.5.tgz",
@ -2676,6 +2684,40 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.2.tgz",
"integrity": "sha512-Ii3MrfY/GAIN3OhXNzpCKaLxHQfJF9qvwq/kEJYdqDxeIHa01K8sldugal6TmeeXl+WMvhv9cnVzUTaFFJF09A==",
"dependencies": {
"@floating-ui/utils": "^0.1.3"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.3.tgz",
"integrity": "sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==",
"dependencies": {
"@floating-ui/core": "^1.4.2",
"@floating-ui/utils": "^0.1.3"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.4.tgz",
"integrity": "sha512-CF8k2rgKeh/49UrnIBs4BdxPUV6vize/Db1d/YbCLyp9GiVZ0BEwf5AiDSxJRCr6yOkGqTFHtmrULxkEfYZ7dQ==",
"dependencies": {
"@floating-ui/dom": "^1.5.1"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz",
"integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A=="
},
"node_modules/@humanwhocodes/config-array": { "node_modules/@humanwhocodes/config-array": {
"version": "0.11.10", "version": "0.11.10",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz",
@ -3933,11 +3975,11 @@
} }
}, },
"node_modules/@mui/types": { "node_modules/@mui/types": {
"version": "7.2.4", "version": "7.2.11",
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.4.tgz", "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.11.tgz",
"integrity": "sha512-LBcwa8rN84bKF+f5sDyku42w1NTxaPgPyYKODsh01U1fVstTClbUoSA96oyRBnSNyEiAVjKm6Gwx9vjR+xyqHA==", "integrity": "sha512-KWe/QTEsFFlFSH+qRYf3zoFEj3z67s+qAuSnMMg+gFwbxG7P96Hm6g300inQL1Wy///gSRb8juX7Wafvp93m3w==",
"peerDependencies": { "peerDependencies": {
"@types/react": "*" "@types/react": "^17.0.0 || ^18.0.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@types/react": { "@types/react": {
@ -3946,25 +3988,134 @@
} }
}, },
"node_modules/@mui/utils": { "node_modules/@mui/utils": {
"version": "5.13.6", "version": "5.15.0",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.13.6.tgz", "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.0.tgz",
"integrity": "sha512-ggNlxl5NPSbp+kNcQLmSig6WVB0Id+4gOxhx644987v4fsji+CSXc+MFYLocFB/x4oHtzCUlSzbVHlJfP/fXoQ==", "integrity": "sha512-XSmTKStpKYamewxyJ256+srwEnsT3/6eNo6G7+WC1tj2Iq9GfUJ/6yUoB7YXjOD2jTZ3XobToZm4pVz1LBt6GA==",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.22.5", "@babel/runtime": "^7.23.5",
"@types/prop-types": "^15.7.5", "@types/prop-types": "^15.7.11",
"@types/react-is": "^18.2.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react-is": "^18.2.0" "react-is": "^18.2.0"
}, },
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
}, },
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0",
"react": "^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/x-date-pickers": {
"version": "6.18.4",
"resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-6.18.4.tgz",
"integrity": "sha512-YqJ6lxZHBIt344B3bvRAVbdYSQz4dcmJQXGcfvJTn26VdKjpgzjAqwhlbQhbAt55audJOWzGB99ImuQuljDROA==",
"dependencies": {
"@babel/runtime": "^7.23.2",
"@mui/base": "^5.0.0-beta.22",
"@mui/utils": "^5.14.16",
"@types/react-transition-group": "^4.4.8",
"clsx": "^2.0.0",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
},
"engines": {
"node": ">=14.0.0"
},
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/mui" "url": "https://opencollective.com/mui"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^17.0.0 || ^18.0.0" "@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1",
"@mui/material": "^5.8.6",
"@mui/system": "^5.8.0",
"date-fns": "^2.25.0",
"date-fns-jalali": "^2.13.0-0",
"dayjs": "^1.10.7",
"luxon": "^3.0.2",
"moment": "^2.29.4",
"moment-hijri": "^2.1.2",
"moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0",
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@emotion/react": {
"optional": true
},
"@emotion/styled": {
"optional": true
},
"date-fns": {
"optional": true
},
"date-fns-jalali": {
"optional": true
},
"dayjs": {
"optional": true
},
"luxon": {
"optional": true
},
"moment": {
"optional": true
},
"moment-hijri": {
"optional": true
},
"moment-jalaali": {
"optional": true
}
}
},
"node_modules/@mui/x-date-pickers/node_modules/@mui/base": {
"version": "5.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.27.tgz",
"integrity": "sha512-duL37qxihT1N0pW/gyXVezP7SttLkF+cLAs/y6g6ubEFmVadjbnZ45SeF12/vAiKzqwf5M0uFH1cczIPXFZygA==",
"dependencies": {
"@babel/runtime": "^7.23.5",
"@floating-ui/react-dom": "^2.0.4",
"@mui/types": "^7.2.11",
"@mui/utils": "^5.15.0",
"@popperjs/core": "^2.11.8",
"clsx": "^2.0.0",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0",
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/x-date-pickers/node_modules/clsx": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz",
"integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==",
"engines": {
"node": ">=6"
} }
}, },
"node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
@ -4842,9 +4993,9 @@
"dev": true "dev": true
}, },
"node_modules/@types/prop-types": { "node_modules/@types/prop-types": {
"version": "15.7.5", "version": "15.7.11",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz",
"integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng=="
}, },
"node_modules/@types/q": { "node_modules/@types/q": {
"version": "1.5.5", "version": "1.5.5",
@ -4882,18 +5033,10 @@
"@types/react": "^17" "@types/react": "^17"
} }
}, },
"node_modules/@types/react-is": {
"version": "18.2.1",
"resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-18.2.1.tgz",
"integrity": "sha512-wyUkmaaSZEzFZivD8F2ftSyAfk6L+DfFliVj/mYdOXbVjRcS87fQJLTnhk6dRZPuJjI+9g6RZJO4PNCngUrmyw==",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react-transition-group": { "node_modules/@types/react-transition-group": {
"version": "4.4.6", "version": "4.4.10",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.6.tgz", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz",
"integrity": "sha512-VnCdSxfcm08KjsJVQcfBmhEQAPnLB8G08hAxn39azX1qYBQ/5RVQuoHuKIcfKOdncuaUvEpFKFzEvbtIMsfVew==", "integrity": "sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==",
"dependencies": { "dependencies": {
"@types/react": "*" "@types/react": "*"
} }
@ -5592,6 +5735,14 @@
"node": ">=8.9" "node": ">=8.9"
} }
}, },
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@ -6547,6 +6698,18 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -6780,6 +6943,14 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/collect-v8-coverage": { "node_modules/collect-v8-coverage": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz",
@ -7030,6 +7201,17 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.3", "version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@ -8926,6 +9108,11 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0" "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
} }
}, },
"node_modules/export-from-json": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/export-from-json/-/export-from-json-1.7.3.tgz",
"integrity": "sha512-Xg0L0saYz+CBz2MnaZvSEAHr17hWtHAfFWXw/frllG9t6aijuQukiU40ElOeM9nDTrtQPhLJMLN0q8lo897FYg=="
},
"node_modules/express": { "node_modules/express": {
"version": "4.18.2", "version": "4.18.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
@ -9423,6 +9610,14 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fraction.js": { "node_modules/fraction.js": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz",
@ -13196,9 +13391,9 @@
} }
}, },
"node_modules/luxon": { "node_modules/luxon": {
"version": "2.5.2", "version": "3.4.4",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-2.5.2.tgz", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz",
"integrity": "sha512-Yg7/RDp4nedqmLgyH0LwgGRvMEKVzKbUdkBYyCosbHgJ+kaOUx0qzSiSatVc3DFygnirTPYnMM2P5dg2uH1WvA==", "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -16148,7 +16343,8 @@
"node_modules/regenerator-runtime": { "node_modules/regenerator-runtime": {
"version": "0.13.11", "version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"dev": true
}, },
"node_modules/regenerator-transform": { "node_modules/regenerator-transform": {
"version": "0.15.1", "version": "0.15.1",
@ -16997,6 +17193,17 @@
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"dev": true "dev": true
}, },
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stable": { "node_modules/stable": {
"version": "0.1.8", "version": "0.1.8",
"resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz",
@ -18746,6 +18953,22 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": { "node_modules/word-wrap": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
@ -19133,6 +19356,26 @@
} }
} }
}, },
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xml-name-validator": { "node_modules/xml-name-validator": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz",

View File

@ -9,29 +9,32 @@
"@mui/lab": "^5.0.0-alpha.89", "@mui/lab": "^5.0.0-alpha.89",
"@mui/material": "^5.8.6", "@mui/material": "^5.8.6",
"@mui/styles": "^5.8.6", "@mui/styles": "^5.8.6",
"@mui/x-date-pickers": "^6.18.4",
"@testing-library/jest-dom": "^5.16.1", "@testing-library/jest-dom": "^5.16.1",
"@testing-library/react": "^12.1.2", "@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"axios": "^0.24.0", "axios": "^0.24.0",
"downshift": "^6.1.12", "downshift": "^6.1.12",
"export-from-json": "^1.7.3",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"luxon": "^2.5.2", "luxon": "^3.4.4",
"markdown-to-jsx": "^7.1.7", "markdown-to-jsx": "^7.1.7",
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-router-dom": "^6.2.1", "react-router-dom": "^6.2.1",
"react-sticky-el": "^2.0.9", "react-sticky-el": "^2.0.9",
"web-vitals": "^2.1.3" "web-vitals": "^2.1.3",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.16.7",
"@playwright/test": "^1.28.1", "@playwright/test": "^1.28.1",
"eslint": "^8.23.1", "eslint": "^8.23.1",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.31.8", "eslint-plugin-react": "^7.31.8",
"prettier": "^2.7.1", "prettier": "^2.7.1",
"react-scripts": "^5.0.1", "react-scripts": "^5.0.1"
"@babel/plugin-proposal-private-property-in-object": "^7.16.7"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",

View File

@ -42,7 +42,8 @@ const config = {
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry', trace: 'on-first-retry',
ignoreHTTPSErrors: true ignoreHTTPSErrors: true,
screenshot: 'only-on-failure'
}, },
/* Configure projects for major browsers */ /* Configure projects for major browsers */
@ -101,7 +102,7 @@ const config = {
], ],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */ /* 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 */ /* Run your local dev server before starting the tests */
// webServer: { // webServer: {

View File

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

View File

@ -34,6 +34,7 @@ const mockImageList = {
Size: '2806985', Size: '2806985',
LastUpdated: '2022-08-09T17:19:53.274069586Z', LastUpdated: '2022-08-09T17:19:53.274069586Z',
IsBookmarked: false, IsBookmarked: false,
IsStarred: false,
NewestImage: { NewestImage: {
Tag: 'latest', Tag: 'latest',
Description: 'w', Description: 'w',
@ -58,6 +59,7 @@ const mockImageList = {
Size: '231383863', Size: '231383863',
LastUpdated: '2022-08-02T01:30:49.193203152Z', LastUpdated: '2022-08-02T01:30:49.193203152Z',
IsBookmarked: false, IsBookmarked: false,
IsStarred: false,
NewestImage: { NewestImage: {
Tag: 'latest', Tag: 'latest',
Description: '', Description: '',
@ -82,6 +84,7 @@ const mockImageList = {
Size: '369311301', Size: '369311301',
LastUpdated: '2022-08-23T00:20:40.144281895Z', LastUpdated: '2022-08-23T00:20:40.144281895Z',
IsBookmarked: false, IsBookmarked: false,
IsStarred: false,
NewestImage: { NewestImage: {
Tag: 'latest', Tag: 'latest',
Description: '', Description: '',
@ -106,6 +109,7 @@ const mockImageList = {
Size: '369311301', Size: '369311301',
LastUpdated: '2022-08-23T00:20:40.144281895Z', LastUpdated: '2022-08-23T00:20:40.144281895Z',
IsBookmarked: false, IsBookmarked: false,
IsStarred: false,
NewestImage: { NewestImage: {
Tag: 'latest', Tag: 'latest',
Description: '', Description: '',
@ -130,6 +134,7 @@ const mockImageList = {
Size: '369311301', Size: '369311301',
LastUpdated: '2022-08-23T00:20:40.144281895Z', LastUpdated: '2022-08-23T00:20:40.144281895Z',
IsBookmarked: false, IsBookmarked: false,
IsStarred: false,
NewestImage: { NewestImage: {
Tag: 'latest', Tag: 'latest',
Description: '', Description: '',
@ -158,6 +163,7 @@ const mockImageList = {
Size: '369311301', Size: '369311301',
LastUpdated: '2022-08-23T00:20:40.144281895Z', LastUpdated: '2022-08-23T00:20:40.144281895Z',
IsBookmarked: false, IsBookmarked: false,
IsStarred: false,
NewestImage: { NewestImage: {
Tag: 'latest', Tag: 'latest',
Description: '', Description: '',
@ -182,6 +188,7 @@ const mockImageList = {
Size: '369311301', Size: '369311301',
LastUpdated: '2022-08-23T00:20:40.144281895Z', LastUpdated: '2022-08-23T00:20:40.144281895Z',
IsBookmarked: false, IsBookmarked: false,
IsStarred: false,
NewestImage: { NewestImage: {
Tag: 'latest', Tag: 'latest',
Description: '', Description: '',
@ -338,4 +345,13 @@ describe('Explore component', () => {
await userEvent.click(bookmarkButton); await userEvent.click(bookmarkButton);
expect(await screen.findAllByTestId('bookmarked')).toHaveLength(1); 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

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

View File

@ -164,6 +164,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(() => { beforeEach(() => {
window.scrollTo = jest.fn(); window.scrollTo = jest.fn();
}); });
@ -178,8 +220,8 @@ describe('Home component', () => {
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } }); jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageListRecent } }); jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageListRecent } });
render(<HomeWrapper />); render(<HomeWrapper />);
await waitFor(() => expect(screen.getAllByText(/alpine/i)).toHaveLength(3)); await waitFor(() => expect(screen.getAllByText(/alpine/i)).toHaveLength(4));
await waitFor(() => expect(screen.getAllByText(/mongo/i)).toHaveLength(3)); await waitFor(() => expect(screen.getAllByText(/mongo/i)).toHaveLength(4));
await waitFor(() => expect(screen.getAllByText(/node/i)).toHaveLength(1)); await waitFor(() => expect(screen.getAllByText(/node/i)).toHaveLength(1));
}); });
@ -187,16 +229,16 @@ describe('Home component', () => {
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } }); jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageListRecent } }); jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageListRecent } });
render(<HomeWrapper />); render(<HomeWrapper />);
expect(await screen.findAllByTestId('unverified-icon')).toHaveLength(3); expect(await screen.findAllByTestId('unverified-icon')).toHaveLength(4);
expect(await screen.findAllByTestId('verified-icon')).toHaveLength(4); expect(await screen.findAllByTestId('verified-icon')).toHaveLength(5);
}); });
it('renders vulnerability icons', async () => { it('renders vulnerability icons', async () => {
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } }); jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageList } });
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageListRecent } }); jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImageListRecent } });
render(<HomeWrapper />); render(<HomeWrapper />);
expect(await screen.findAllByTestId('low-vulnerability-icon')).toHaveLength(3); expect(await screen.findAllByTestId('low-vulnerability-icon')).toHaveLength(4);
expect(await screen.findAllByTestId('high-vulnerability-icon')).toHaveLength(3); expect(await screen.findAllByTestId('high-vulnerability-icon')).toHaveLength(4);
expect(await screen.findAllByTestId('critical-vulnerability-icon')).toHaveLength(1); expect(await screen.findAllByTestId('critical-vulnerability-icon')).toHaveLength(1);
}); });
@ -204,16 +246,17 @@ describe('Home component', () => {
jest.spyOn(api, 'get').mockRejectedValue({ status: 500, data: {} }); jest.spyOn(api, 'get').mockRejectedValue({ status: 500, data: {} });
const error = jest.spyOn(console, 'error').mockImplementation(() => {}); const error = jest.spyOn(console, 'error').mockImplementation(() => {});
render(<HomeWrapper />); 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 () => { 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: mockImageList } });
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageListRecent } }); 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: mockImageListBookmarks } });
jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockImageListStars } });
render(<HomeWrapper />); render(<HomeWrapper />);
const viewAllButtons = await screen.findAllByText(/view all/i); 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: [] } }); jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: [] } });
fireEvent.click(viewAllButtons[0]); fireEvent.click(viewAllButtons[0]);
expect(mockedUsedNavigate).toHaveBeenCalledWith({ expect(mockedUsedNavigate).toHaveBeenCalledWith({
@ -230,5 +273,10 @@ describe('Home component', () => {
pathname: `/explore`, pathname: `/explore`,
search: createSearchParams({ filter: 'IsBookmarked' }).toString() search: createSearchParams({ filter: 'IsBookmarked' }).toString()
}); });
fireEvent.click(viewAllButtons[3]);
expect(mockedUsedNavigate).toHaveBeenCalledWith({
pathname: `/explore`,
search: createSearchParams({ filter: 'IsStarred' }).toString()
});
}); });
}); });

View File

@ -7,7 +7,7 @@ import userEvent from '@testing-library/user-event';
const mockMgmtResponse = { const mockMgmtResponse = {
distSpecVersion: '1.1.0-dev', distSpecVersion: '1.1.0-dev',
binaryType: '-apikey-lint-metrics-mgmt-scrub-search-sync-ui-userprefs', binaryType: '-apikey-lint-metrics-mgmt-scrub-search-sync-ui-userprefs',
http: { auth: { htpasswd: {} } } http: { auth: { htpasswd: {}, openid: { providers: { github: {} } } } }
}; };
// useNavigate mock // useNavigate mock
@ -55,6 +55,7 @@ describe('Sign in form', () => {
fireEvent.change(passwordInput, { target: { value: 'test' } }); fireEvent.change(passwordInput, { target: { value: 'test' } });
expect(usernameInput).toHaveValue('test'); expect(usernameInput).toHaveValue('test');
expect(passwordInput).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 () => { it('should display error if username and password values are empty after change', async () => {

View File

@ -47,6 +47,7 @@ const mockRepoDetailsData = {
Size: '451554070', Size: '451554070',
Vendors: ['[The Node.js Docker Team](https://github.com/nodejs/docker-node)\n'], Vendors: ['[The Node.js Docker Team](https://github.com/nodejs/docker-node)\n'],
IsBookmarked: false, IsBookmarked: false,
IsStarred: false,
NewestImage: { NewestImage: {
RepoName: 'mongo', RepoName: 'mongo',
IsSigned: true, IsSigned: true,
@ -316,4 +317,13 @@ describe('Repo details component', () => {
await userEvent.click(bookmarkButton); await userEvent.click(bookmarkButton);
expect(await screen.findByTestId('bookmarked')).toBeInTheDocument(); 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

@ -22,6 +22,7 @@ const mockedTagsData = [
{ {
tag: 'latest', tag: 'latest',
vendor: 'test1', vendor: 'test1',
isDeletable: true,
manifests: [ manifests: [
{ {
lastUpdated: '2022-07-19T18:06:18.818788283Z', lastUpdated: '2022-07-19T18:06:18.818788283Z',
@ -37,6 +38,7 @@ const mockedTagsData = [
{ {
tag: 'bullseye', tag: 'bullseye',
vendor: 'test1', vendor: 'test1',
isDeletable: true,
manifests: [ manifests: [
{ {
digest: 'sha256:adca4815c494becc1bf053af0c4640b2d81ab1a779e6d649e1b8b92a75f1d559', digest: 'sha256:adca4815c494becc1bf053af0c4640b2d81ab1a779e6d649e1b8b92a75f1d559',
@ -52,6 +54,7 @@ const mockedTagsData = [
{ {
tag: '1.5.2', tag: '1.5.2',
vendor: 'test1', vendor: 'test1',
isDeletable: true,
manifests: [ manifests: [
{ {
lastUpdated: '2022-07-19T18:06:18.818788283Z', lastUpdated: '2022-07-19T18:06:18.818788283Z',
@ -76,6 +79,18 @@ describe('Tags component', () => {
await waitFor(() => expect(screen.queryByText(/OS\/ARCH/i)).not.toBeInTheDocument()); 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 () => { it('should navigate to tag page details when tag is clicked', async () => {
render(<TagsThemeWrapper />); render(<TagsThemeWrapper />);
const tagLink = await screen.findByText('latest'); const tagLink = await screen.findByText('latest');

View File

@ -1,4 +1,4 @@
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 userEvent from '@testing-library/user-event';
import MockThemeProvider from '__mocks__/MockThemeProvider'; import MockThemeProvider from '__mocks__/MockThemeProvider';
import { api } from 'api'; import { api } from 'api';
@ -6,6 +6,8 @@ import VulnerabilitiesDetails from 'components/Tag/Tabs/VulnerabilitiesDetails';
import React from 'react'; import React from 'react';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
jest.mock('xlsx');
const StateVulnerabilitiesWrapper = () => { const StateVulnerabilitiesWrapper = () => {
return ( return (
<MockThemeProvider> <MockThemeProvider>
@ -16,10 +18,18 @@ const StateVulnerabilitiesWrapper = () => {
); );
}; };
const mockCVEList = { const simpleMockCVEList = {
CVEListForImage: { CVEListForImage: {
Tag: '', 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: [ CVEList: [
{ {
Id: 'CVE-2020-16156', Id: 'CVE-2020-16156',
@ -29,6 +39,53 @@ const mockCVEList = {
PackageList: [ PackageList: [
{ {
Name: 'perl-base', 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', InstalledVersion: '5.30.0-9ubuntu0.2',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
} }
@ -44,26 +101,31 @@ const mockCVEList = {
PackageList: [ PackageList: [
{ {
Name: 'krb5-locales', Name: 'krb5-locales',
PackagePath: 'Not Specified',
InstalledVersion: '1.17-6ubuntu4.1', InstalledVersion: '1.17-6ubuntu4.1',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
}, },
{ {
Name: 'libgssapi-krb5-2', Name: 'libgssapi-krb5-2',
PackagePath: 'Not Specified',
InstalledVersion: '1.17-6ubuntu4.1', InstalledVersion: '1.17-6ubuntu4.1',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
}, },
{ {
Name: 'libk5crypto3', Name: 'libk5crypto3',
PackagePath: 'Not Specified',
InstalledVersion: '1.17-6ubuntu4.1', InstalledVersion: '1.17-6ubuntu4.1',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
}, },
{ {
Name: 'libkrb5-3', Name: 'libkrb5-3',
PackagePath: 'Not Specified',
InstalledVersion: '1.17-6ubuntu4.1', InstalledVersion: '1.17-6ubuntu4.1',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
}, },
{ {
Name: 'libkrb5support0', Name: 'libkrb5support0',
PackagePath: 'Not Specified',
InstalledVersion: '1.17-6ubuntu4.1', InstalledVersion: '1.17-6ubuntu4.1',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
} }
@ -78,6 +140,7 @@ const mockCVEList = {
PackageList: [ PackageList: [
{ {
Name: 'libgnutls30', Name: 'libgnutls30',
PackagePath: 'Not Specified',
InstalledVersion: '3.6.13-2ubuntu1.6', InstalledVersion: '3.6.13-2ubuntu1.6',
FixedVersion: '3.6.13-2ubuntu1.7' FixedVersion: '3.6.13-2ubuntu1.7'
} }
@ -92,6 +155,7 @@ const mockCVEList = {
PackageList: [ PackageList: [
{ {
Name: 'libpcre2-8-0', Name: 'libpcre2-8-0',
PackagePath: 'Not Specified',
InstalledVersion: '10.34-7', InstalledVersion: '10.34-7',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
} }
@ -106,6 +170,7 @@ const mockCVEList = {
PackageList: [ PackageList: [
{ {
Name: 'libsqlite3-0', Name: 'libsqlite3-0',
PackagePath: 'Not Specified',
InstalledVersion: '3.31.1-4ubuntu0.3', InstalledVersion: '3.31.1-4ubuntu0.3',
FixedVersion: '3.31.1-4ubuntu0.4' FixedVersion: '3.31.1-4ubuntu0.4'
} }
@ -120,6 +185,7 @@ const mockCVEList = {
PackageList: [ PackageList: [
{ {
Name: 'libpcre3', Name: 'libpcre3',
PackagePath: 'Not Specified',
InstalledVersion: '2:8.39-12ubuntu0.1', InstalledVersion: '2:8.39-12ubuntu0.1',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
} }
@ -134,6 +200,7 @@ const mockCVEList = {
PackageList: [ PackageList: [
{ {
Name: 'libsqlite3-0', Name: 'libsqlite3-0',
PackagePath: 'Not Specified',
InstalledVersion: '3.31.1-4ubuntu0.3', InstalledVersion: '3.31.1-4ubuntu0.3',
FixedVersion: '3.31.1-4ubuntu0.4' FixedVersion: '3.31.1-4ubuntu0.4'
} }
@ -148,11 +215,13 @@ const mockCVEList = {
PackageList: [ PackageList: [
{ {
Name: 'login', Name: 'login',
PackagePath: 'Not Specified',
InstalledVersion: '1:4.8.1-1ubuntu5.20.04.2', InstalledVersion: '1:4.8.1-1ubuntu5.20.04.2',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
}, },
{ {
Name: 'passwd', Name: 'passwd',
PackagePath: 'Not Specified',
InstalledVersion: '1:4.8.1-1ubuntu5.20.04.2', InstalledVersion: '1:4.8.1-1ubuntu5.20.04.2',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
} }
@ -167,6 +236,7 @@ const mockCVEList = {
PackageList: [ PackageList: [
{ {
Name: 'libgmp10', Name: 'libgmp10',
PackagePath: 'Not Specified',
InstalledVersion: '2:6.2.0+dfsg-4', InstalledVersion: '2:6.2.0+dfsg-4',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
} }
@ -181,6 +251,7 @@ const mockCVEList = {
PackageList: [ PackageList: [
{ {
Name: 'libgnutls30', Name: 'libgnutls30',
PackagePath: 'Not Specified',
InstalledVersion: '3.6.13-2ubuntu1.6', InstalledVersion: '3.6.13-2ubuntu1.6',
FixedVersion: '3.6.13-2ubuntu1.7' FixedVersion: '3.6.13-2ubuntu1.7'
} }
@ -195,26 +266,31 @@ const mockCVEList = {
PackageList: [ PackageList: [
{ {
Name: 'libncurses6', Name: 'libncurses6',
PackagePath: 'Not Specified',
InstalledVersion: '6.2-0ubuntu2', InstalledVersion: '6.2-0ubuntu2',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
}, },
{ {
Name: 'libncursesw6', Name: 'libncursesw6',
PackagePath: 'Not Specified',
InstalledVersion: '6.2-0ubuntu2', InstalledVersion: '6.2-0ubuntu2',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
}, },
{ {
Name: 'libtinfo6', Name: 'libtinfo6',
PackagePath: 'Not Specified',
InstalledVersion: '6.2-0ubuntu2', InstalledVersion: '6.2-0ubuntu2',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
}, },
{ {
Name: 'ncurses-base', Name: 'ncurses-base',
PackagePath: 'Not Specified',
InstalledVersion: '6.2-0ubuntu2', InstalledVersion: '6.2-0ubuntu2',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
}, },
{ {
Name: 'ncurses-bin', Name: 'ncurses-bin',
PackagePath: 'Not Specified',
InstalledVersion: '6.2-0ubuntu2', InstalledVersion: '6.2-0ubuntu2',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
} }
@ -229,6 +305,7 @@ const mockCVEList = {
PackageList: [ PackageList: [
{ {
Name: 'libpcre2-8-0', Name: 'libpcre2-8-0',
PackagePath: 'Not Specified',
InstalledVersion: '10.34-7', InstalledVersion: '10.34-7',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
} }
@ -243,26 +320,31 @@ const mockCVEList = {
PackageList: [ PackageList: [
{ {
Name: 'libncurses6', Name: 'libncurses6',
PackagePath: 'Not Specified',
InstalledVersion: '6.2-0ubuntu2', InstalledVersion: '6.2-0ubuntu2',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
}, },
{ {
Name: 'libncursesw6', Name: 'libncursesw6',
PackagePath: 'Not Specified',
InstalledVersion: '6.2-0ubuntu2', InstalledVersion: '6.2-0ubuntu2',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
}, },
{ {
Name: 'libtinfo6', Name: 'libtinfo6',
PackagePath: 'Not Specified',
InstalledVersion: '6.2-0ubuntu2', InstalledVersion: '6.2-0ubuntu2',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
}, },
{ {
Name: 'ncurses-base', Name: 'ncurses-base',
PackagePath: 'Not Specified',
InstalledVersion: '6.2-0ubuntu2', InstalledVersion: '6.2-0ubuntu2',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
}, },
{ {
Name: 'ncurses-bin', Name: 'ncurses-bin',
PackagePath: 'Not Specified',
InstalledVersion: '6.2-0ubuntu2', InstalledVersion: '6.2-0ubuntu2',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
} }
@ -277,6 +359,7 @@ const mockCVEList = {
PackageList: [ PackageList: [
{ {
Name: 'coreutils', Name: 'coreutils',
PackagePath: 'Not Specified',
InstalledVersion: '8.30-3ubuntu2', InstalledVersion: '8.30-3ubuntu2',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
} }
@ -291,46 +374,55 @@ const mockCVEList = {
PackageList: [ PackageList: [
{ {
Name: 'libasn1-8-heimdal', Name: 'libasn1-8-heimdal',
PackagePath: 'Not Specified',
InstalledVersion: '7.7.0+dfsg-1ubuntu1', InstalledVersion: '7.7.0+dfsg-1ubuntu1',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
}, },
{ {
Name: 'libgssapi3-heimdal', Name: 'libgssapi3-heimdal',
PackagePath: 'Not Specified',
InstalledVersion: '7.7.0+dfsg-1ubuntu1', InstalledVersion: '7.7.0+dfsg-1ubuntu1',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
}, },
{ {
Name: 'libhcrypto4-heimdal', Name: 'libhcrypto4-heimdal',
PackagePath: 'Not Specified',
InstalledVersion: '7.7.0+dfsg-1ubuntu1', InstalledVersion: '7.7.0+dfsg-1ubuntu1',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
}, },
{ {
Name: 'libheimbase1-heimdal', Name: 'libheimbase1-heimdal',
PackagePath: 'Not Specified',
InstalledVersion: '7.7.0+dfsg-1ubuntu1', InstalledVersion: '7.7.0+dfsg-1ubuntu1',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
}, },
{ {
Name: 'libheimntlm0-heimdal', Name: 'libheimntlm0-heimdal',
PackagePath: 'Not Specified',
InstalledVersion: '7.7.0+dfsg-1ubuntu1', InstalledVersion: '7.7.0+dfsg-1ubuntu1',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
}, },
{ {
Name: 'libhx509-5-heimdal', Name: 'libhx509-5-heimdal',
PackagePath: 'Not Specified',
InstalledVersion: '7.7.0+dfsg-1ubuntu1', InstalledVersion: '7.7.0+dfsg-1ubuntu1',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
}, },
{ {
Name: 'libkrb5-26-heimdal', Name: 'libkrb5-26-heimdal',
PackagePath: 'Not Specified',
InstalledVersion: '7.7.0+dfsg-1ubuntu1', InstalledVersion: '7.7.0+dfsg-1ubuntu1',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
}, },
{ {
Name: 'libroken18-heimdal', Name: 'libroken18-heimdal',
PackagePath: 'Not Specified',
InstalledVersion: '7.7.0+dfsg-1ubuntu1', InstalledVersion: '7.7.0+dfsg-1ubuntu1',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
}, },
{ {
Name: 'libwind0-heimdal', Name: 'libwind0-heimdal',
PackagePath: 'Not Specified',
InstalledVersion: '7.7.0+dfsg-1ubuntu1', InstalledVersion: '7.7.0+dfsg-1ubuntu1',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
} }
@ -345,11 +437,13 @@ const mockCVEList = {
PackageList: [ PackageList: [
{ {
Name: 'libc-bin', Name: 'libc-bin',
PackagePath: 'Not Specified',
InstalledVersion: '2.31-0ubuntu9.9', InstalledVersion: '2.31-0ubuntu9.9',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
}, },
{ {
Name: 'libc6', Name: 'libc6',
PackagePath: 'Not Specified',
InstalledVersion: '2.31-0ubuntu9.9', InstalledVersion: '2.31-0ubuntu9.9',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
} }
@ -363,6 +457,7 @@ const mockCVEList = {
PackageList: [ PackageList: [
{ {
Name: 'libcurl4', Name: 'libcurl4',
PackagePath: 'Not Specified',
InstalledVersion: '7.68.0-1ubuntu2.12', InstalledVersion: '7.68.0-1ubuntu2.12',
FixedVersion: '7.68.0-1ubuntu2.13' FixedVersion: '7.68.0-1ubuntu2.13'
} }
@ -378,26 +473,31 @@ const mockCVEList = {
PackageList: [ PackageList: [
{ {
Name: 'krb5-locales', Name: 'krb5-locales',
PackagePath: 'Not Specified',
InstalledVersion: '1.17-6ubuntu4.1', InstalledVersion: '1.17-6ubuntu4.1',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
}, },
{ {
Name: 'libgssapi-krb5-2', Name: 'libgssapi-krb5-2',
PackagePath: 'Not Specified',
InstalledVersion: '1.17-6ubuntu4.1', InstalledVersion: '1.17-6ubuntu4.1',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
}, },
{ {
Name: 'libk5crypto3', Name: 'libk5crypto3',
PackagePath: 'Not Specified',
InstalledVersion: '1.17-6ubuntu4.1', InstalledVersion: '1.17-6ubuntu4.1',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
}, },
{ {
Name: 'libkrb5-3', Name: 'libkrb5-3',
PackagePath: 'Not Specified',
InstalledVersion: '1.17-6ubuntu4.1', InstalledVersion: '1.17-6ubuntu4.1',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
}, },
{ {
Name: 'libkrb5support0', Name: 'libkrb5support0',
PackagePath: 'Not Specified',
InstalledVersion: '1.17-6ubuntu4.1', InstalledVersion: '1.17-6ubuntu4.1',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
} }
@ -412,6 +512,7 @@ const mockCVEList = {
PackageList: [ PackageList: [
{ {
Name: 'libsqlite3-0', Name: 'libsqlite3-0',
PackagePath: 'Not Specified',
InstalledVersion: '3.31.1-4ubuntu0.3', InstalledVersion: '3.31.1-4ubuntu0.3',
FixedVersion: '3.31.1-4ubuntu0.4' FixedVersion: '3.31.1-4ubuntu0.4'
} }
@ -427,6 +528,7 @@ const mockCVEList = {
PackageList: [ PackageList: [
{ {
Name: 'zlib1g', Name: 'zlib1g',
PackagePath: 'Not Specified',
InstalledVersion: '1:1.2.11.dfsg-2ubuntu1.3', InstalledVersion: '1:1.2.11.dfsg-2ubuntu1.3',
FixedVersion: 'Not Specified' FixedVersion: 'Not Specified'
} }
@ -440,10 +542,52 @@ const mockCVEListFiltered = {
CVEListForImage: { CVEListForImage: {
Tag: '', Tag: '',
Page: { ItemCount: 20, TotalCount: 20 }, 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')) 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 = { const mockCVEFixed = {
pageOne: { pageOne: {
ImageListWithCVEFixed: { ImageListWithCVEFixed: {
@ -497,41 +641,97 @@ describe('Vulnerabilties page', () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEList } }); jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEList } });
render(<StateVulnerabilitiesWrapper />); render(<StateVulnerabilitiesWrapper />);
await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1)); 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 () => { 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 />); render(<StateVulnerabilitiesWrapper />);
const cveSearchInput = screen.getByPlaceholderText(/search/i); const cveSearchInput = screen.getByPlaceholderText(/search/i);
jest.spyOn(api, 'get').mockRejectedValueOnce({ status: 200, data: { data: mockCVEListFiltered } }); const expandSearch = cveSearchInput.parentElement.parentElement.parentElement.parentElement.childNodes[0];
await userEvent.type(cveSearchInput, '2022'); await fireEvent.click(expandSearch);
expect((await screen.queryAllByText(/2023/i).length) === 0); await waitFor(() =>
expect((await screen.findAllByText(/2022/i)).length === 6); expect(screen.getAllByPlaceholderText("Exclude")).toHaveLength(1)
}); );
const excludeInput = screen.getByPlaceholderText("Exclude");
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 () => { it('renders no vulnerabilities if there are not any', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ jest.spyOn(api, 'get').mockResolvedValue({
status: 200, status: 200,
data: { data: { CVEListForImage: { Tag: '', Page: {}, CVEList: [] } } } data: { data: { CVEListForImage: { Tag: '', Page: {}, CVEList: [], Summary: {} } } }
}); });
render(<StateVulnerabilitiesWrapper />); render(<StateVulnerabilitiesWrapper />);
await waitFor(() => expect(screen.getAllByText('No Vulnerabilities')).toHaveLength(1)); await waitFor(() => expect(screen.getAllByText('No Vulnerabilities')).toHaveLength(1));
}); });
it('should open and close description dropdown for vulnerabilities', async () => { it('should show description for vulnerabilities', async () => {
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEList } }); jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } })
.mockResolvedValue({ status: 200, data: { data: mockCVEFixed.pageOne } });
render(<StateVulnerabilitiesWrapper />); render(<StateVulnerabilitiesWrapper />);
await waitFor(() => expect(screen.getAllByText(/description/i)).toHaveLength(20)); const expandListBtn = await screen.findAllByTestId('ViewAgendaIcon');
const openText = screen.getAllByText(/description/i); fireEvent.click(expandListBtn[0]);
await fireEvent.click(openText[0]); await waitFor(() => expect(screen.getAllByText(/Description/)).toHaveLength(20));
await waitFor(() => await waitFor(() =>
expect(screen.getAllByText(/CPAN 2.28 allows Signature Verification Bypass./i)).toHaveLength(1) 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 () => { it("should log an error when data can't be fetched", async () => {
@ -549,15 +749,113 @@ describe('Vulnerabilties page', () => {
.mockResolvedValueOnce({ status: 200, data: { data: mockCVEFixed.pageTwo } }); .mockResolvedValueOnce({ status: 200, data: { data: mockCVEFixed.pageTwo } });
render(<StateVulnerabilitiesWrapper />); render(<StateVulnerabilitiesWrapper />);
await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1)); 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()); await waitFor(() => expect(screen.getByText('1.0.16')).toBeInTheDocument());
const loadMoreBtn = screen.getByText(/load more/i); await waitFor(() => expect(screen.getAllByText(/Load more/).length).toBe(1));
expect(loadMoreBtn).toBeInTheDocument(); const loadMoreBtn = screen.getAllByText(/Load more/)[0];
await fireEvent.click(loadMoreBtn); await fireEvent.click(loadMoreBtn);
await waitFor(() => expect(loadMoreBtn).not.toBeInTheDocument()); await waitFor(() => expect(loadMoreBtn).not.toBeInTheDocument());
expect(await screen.findByText('latest')).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');
fireEvent.click(downloadBtn[0]);
expect(await screen.findByTestId('export-csv-menuItem')).toBeInTheDocument();
expect(await screen.findByTestId('export-excel-menuItem')).toBeInTheDocument();
const exportAsCSVBtn = screen.getByText(/csv/i);
expect(exportAsCSVBtn).toBeInTheDocument();
global.URL.createObjectURL = jest.fn();
await fireEvent.click(exportAsCSVBtn);
expect(await screen.findByTestId('export-csv-menuItem')).not.toBeInTheDocument();
fireEvent.click(downloadBtn[0]);
const exportAsExcelBtn = screen.getByText(/xlsx/i);
expect(exportAsExcelBtn).toBeInTheDocument();
await fireEvent.click(exportAsExcelBtn);
expect(await screen.findByTestId('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');
fireEvent.click(expandListBtn[0]);
await waitFor(() => expect(screen.getAllByText('Fixed in')).toHaveLength(20));
const collapseListBtn = await screen.findAllByTestId('ViewHeadlineIcon');
fireEvent.click(collapseListBtn[0]);
expect(await screen.findByText('Fixed in')).not.toBeVisible();
});
it('should handle fixed CVE query errors', async () => { it('should handle fixed CVE query errors', async () => {
jest jest
.spyOn(api, 'get') .spyOn(api, 'get')
@ -566,7 +864,8 @@ describe('Vulnerabilties page', () => {
render(<StateVulnerabilitiesWrapper />); render(<StateVulnerabilitiesWrapper />);
await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1)); await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1));
const error = jest.spyOn(console, 'error').mockImplementation(() => {}); 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(screen.getByText(/not fixed/i)).toBeInTheDocument());
await waitFor(() => expect(error).toBeCalledTimes(1)); await waitFor(() => expect(error).toBeCalledTimes(1));
}); });

View File

@ -67,11 +67,14 @@ const api = {
return axios.put(urli, payload, config); return axios.put(urli, payload, config);
}, },
delete(urli, abortSignal, cfg) { delete(urli, params, abortSignal, cfg) {
let config = isEmpty(cfg) ? this.getRequestCfg() : cfg; let config = isEmpty(cfg) ? this.getRequestCfg() : cfg;
if (!isEmpty(abortSignal) && isEmpty(config.signal)) { if (!isEmpty(abortSignal) && isEmpty(config.signal)) {
config = { ...config, signal: abortSignal }; config = { ...config, signal: abortSignal };
} }
if (!isEmpty(params)) {
config = { ...config, params };
}
return axios.delete(urli, config); return axios.delete(urli, config);
} }
}; };
@ -79,25 +82,41 @@ const api = {
const endpoints = { const endpoints = {
status: `/v2/`, status: `/v2/`,
authConfig: `/v2/_zot/ext/mgmt`, authConfig: `/v2/_zot/ext/mgmt`,
openidAuth: `/auth/login`, openidAuth: `/zot/auth/login`,
logout: `/auth/logout`, logout: `/zot/auth/logout`,
apiKeys: '/zot/auth/apikey',
deleteImage: (name, tag) => `/v2/${name}/manifests/${tag}`,
repoList: ({ pageNumber = 1, pageSize = 15 } = {}) => repoList: ({ pageNumber = 1, pageSize = 15 } = {}) =>
`/v2/_zot/ext/search?query={RepoListWithNewestImage(requestedPage: {limit:${pageSize} offset:${ `/v2/_zot/ext/search?query={RepoListWithNewestImage(requestedPage: {limit:${pageSize} offset:${
(pageNumber - 1) * pageSize (pageNumber - 1) * pageSize
}}){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 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) => 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 SignatureInfo { Tool IsTrusted Author } 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) => detailedImageInfo: (name, tag) =>
`/v2/_zot/ext/search?query={Image(image: "${name}:${tag}"){RepoName IsSigned SignatureInfo { Tool IsTrusted Author } Vulnerabilities {MaxSeverity Count} Referrers {MediaType ArtifactType Size Digest Annotations{Key Value}} Tag Manifests {History {Layer {Size Digest} HistoryDescription {CreatedBy EmptyLayer}} Digest ConfigDigest LastUpdated Size Platform {Os Arch}} Vendor Licenses }}`, `/v2/_zot/ext/search?query={Image(image: "${name}:${tag}"){RepoName IsSigned SignatureInfo { Tool IsTrusted Author } Vulnerabilities {MaxSeverity Count} Referrers {MediaType ArtifactType Size Digest Annotations{Key Value}} Tag Manifests {History {Layer {Size Digest} HistoryDescription {CreatedBy EmptyLayer}} Digest ConfigDigest LastUpdated Size Platform {Os Arch}} Vendor Licenses }}`,
vulnerabilitiesForRepo: (name, { pageNumber = 1, pageSize = 15 }, searchTerm = '') => { vulnerabilitiesForRepo: (
name,
{ pageNumber = 1, pageSize = 15 },
searchTerm = '',
excludedTerm = '',
severity = ''
) => {
let query = `/v2/_zot/ext/search?query={CVEListForImage(image: "${name}", requestedPage: {limit:${pageSize} offset:${ let query = `/v2/_zot/ext/search?query={CVEListForImage(image: "${name}", requestedPage: {limit:${pageSize} offset:${
(pageNumber - 1) * pageSize (pageNumber - 1) * pageSize
}}`; }}`;
if (!isEmpty(searchTerm)) { if (!isEmpty(searchTerm)) {
query += `, searchedCVE: "${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}}}`;
}, },
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 = {}) => { imageListWithCVEFixed: (cveId, repoName, { pageNumber = 1, pageSize = 3 }, filter = {}) => {
let filterParam = ''; let filterParam = '';
if (filter.Os || filter.Arch) { if (filter.Os || filter.Arch) {
@ -134,9 +153,10 @@ const endpoints = {
if (filter.Arch) filterParam += ` Arch:${!isEmpty(filter.Arch) ? `${JSON.stringify(filter.Arch)}` : '""'}`; if (filter.Arch) filterParam += ` Arch:${!isEmpty(filter.Arch) ? `${JSON.stringify(filter.Arch)}` : '""'}`;
if (filter.HasToBeSigned) filterParam += ` HasToBeSigned: ${filter.HasToBeSigned}`; if (filter.HasToBeSigned) filterParam += ` HasToBeSigned: ${filter.HasToBeSigned}`;
if (filter.IsBookmarked) filterParam += ` IsBookmarked: ${filter.IsBookmarked}`; if (filter.IsBookmarked) filterParam += ` IsBookmarked: ${filter.IsBookmarked}`;
if (filter.IsStarred) filterParam += ` IsStarred: ${filter.IsStarred}`;
filterParam += '}'; filterParam += '}';
if (Object.keys(filter).length === 0) 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 SignatureInfo { Tool IsTrusted Author } 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 }) => { imageSuggestions: ({ searchQuery = '""', pageNumber = 1, pageSize = 15 }) => {
const searchParam = searchQuery !== '' ? `query:"${searchQuery}"` : `query:""`; const searchParam = searchQuery !== '' ? `query:"${searchQuery}"` : `query:""`;
@ -145,7 +165,8 @@ const endpoints = {
}, },
referrers: ({ repo, digest, type = '' }) => referrers: ({ repo, digest, type = '' }) =>
`/v2/_zot/ext/search?query={Referrers(repo: "${repo}" digest: "${digest}" type: "${type}"){MediaType ArtifactType Size Digest Annotations{Key Value}}}`, `/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 }; export { api, endpoints };

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

@ -220,9 +220,11 @@ function Explore({ searchInputValue }) {
version={item.latestVersion} version={item.latestVersion}
description={item.description} description={item.description}
downloads={item.downloads} downloads={item.downloads}
stars={item.stars}
isSigned={item.isSigned} isSigned={item.isSigned}
signatureInfo={item.signatureInfo} signatureInfo={item.signatureInfo}
isBookmarked={item.isBookmarked} isBookmarked={item.isBookmarked}
isStarred={item.isStarred}
vendor={item.vendor} vendor={item.vendor}
platforms={item.platforms} platforms={item.platforms}
key={index} key={index}

View File

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

View File

@ -132,8 +132,14 @@ function SearchSuggestion({ setSearchCurrentValue = () => {} }) {
const handleSearch = (event) => { const handleSearch = (event) => {
const { key, type } = event; const { key, type } = event;
const name = event.target.value;
if (key === 'Enter' || type === 'click') { 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 <List
{...getMenuProps()} {...getMenuProps()}
className={ className={
isOpen && !isLoading && !isFailedSearch isOpen && !isFailedSearch
? `${classes.resultsWrapper} ${isComponentFocused && classes.resultsWrapperFocused}` ? `${classes.resultsWrapper} ${isComponentFocused && classes.resultsWrapperFocused}`
: classes.resultsWrapperHidden : classes.resultsWrapperHidden
} }
> >
{isOpen && suggestionData?.length > 0 && renderSuggestions()} {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) && ( {isOpen && isEmpty(searchQuery) && isEmpty(suggestionData) && (
<> <>
<ListItem <ListItem

View File

@ -2,11 +2,17 @@ import React, { useState } from 'react';
import { Menu, MenuItem, IconButton, Avatar, Divider } from '@mui/material'; 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-dom';
function UserAccountMenu() { function UserAccountMenu() {
const [anchorEl, setAnchorEl] = useState(null); const [anchorEl, setAnchorEl] = useState(null);
const openMenu = Boolean(anchorEl); const openMenu = Boolean(anchorEl);
const navigate = useNavigate();
const apiKeyManagement = () => {
navigate('/user/apikey');
};
const handleUserClick = (event) => { const handleUserClick = (event) => {
setAnchorEl(event.currentTarget); setAnchorEl(event.currentTarget);
@ -37,6 +43,8 @@ function UserAccountMenu() {
> >
<MenuItem onClick={handleUserClose}>{getLoggedInUser()}</MenuItem> <MenuItem onClick={handleUserClose}>{getLoggedInUser()}</MenuItem>
<Divider /> <Divider />
{isApiKeyEnabled() && <MenuItem onClick={apiKeyManagement}>API Keys</MenuItem>}
<Divider />
<MenuItem onClick={logoutUser}>Log out</MenuItem> <MenuItem onClick={logoutUser}>Log out</MenuItem>
</Menu> </Menu>
</> </>

View File

@ -8,7 +8,12 @@ import { mapToRepo } from 'utilities/objectModels';
import Loading from '../Shared/Loading'; import Loading from '../Shared/Loading';
import { useNavigate, createSearchParams } from 'react-router-dom'; import { useNavigate, createSearchParams } from 'react-router-dom';
import { sortByCriteria } from 'utilities/sortCriteria'; 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 { isEmpty } from 'lodash';
import NoDataComponent from 'components/Shared/NoDataComponent'; import NoDataComponent from 'components/Shared/NoDataComponent';
@ -89,6 +94,8 @@ function Home() {
const [isLoadingRecent, setIsLoadingRecent] = useState(true); const [isLoadingRecent, setIsLoadingRecent] = useState(true);
const [bookmarkData, setBookmarkData] = useState([]); const [bookmarkData, setBookmarkData] = useState([]);
const [isLoadingBookmarks, setIsLoadingBookmarks] = useState(true); const [isLoadingBookmarks, setIsLoadingBookmarks] = useState(true);
const [starData, setStarData] = useState([]);
const [isLoadingStars, setIsLoadingStars] = useState(true);
const navigate = useNavigate(); const navigate = useNavigate();
const abortController = useMemo(() => new AbortController(), []); const abortController = useMemo(() => new AbortController(), []);
@ -185,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(() => { useEffect(() => {
window.scrollTo(0, 0); window.scrollTo(0, 0);
setIsLoading(true); setIsLoading(true);
getPopularData(); getPopularData();
getRecentData(); getRecentData();
getBookmarks(); getBookmarks();
getStars();
return () => { return () => {
abortController.abort(); abortController.abort();
}; };
@ -200,11 +239,18 @@ function Home() {
navigate({ pathname: `/explore`, search: createSearchParams({ [type]: value }).toString() }); navigate({ pathname: `/explore`, search: createSearchParams({ [type]: value }).toString() });
}; };
const renderCards = (cardArray, isLoading) => { const isNoData = () =>
if (cardArray && cardArray.length < 1 && !isLoading) { !isLoading &&
return <NoDataComponent text="No images" />; !isLoadingBookmarks &&
} !isLoadingStars &&
!isLoadingPopular &&
!isLoadingRecent &&
bookmarkData.length === 0 &&
starData.length === 0 &&
popularData.length === 0 &&
recentData.length === 0;
const renderCards = (cardArray) => {
return ( return (
cardArray && cardArray &&
cardArray.map((item, index) => { cardArray.map((item, index) => {
@ -214,9 +260,11 @@ function Home() {
version={item.latestVersion} version={item.latestVersion}
description={item.description} description={item.description}
downloads={item.downloads} downloads={item.downloads}
stars={item.stars}
isSigned={item.isSigned} isSigned={item.isSigned}
signatureInfo={item.signatureInfo} signatureInfo={item.signatureInfo}
isBookmarked={item.isBookmarked} isBookmarked={item.isBookmarked}
isStarred={item.isStarred}
vendor={item.vendor} vendor={item.vendor}
platforms={item.platforms} platforms={item.platforms}
key={index} key={index}
@ -232,68 +280,89 @@ function Home() {
); );
}; };
return ( const renderContent = () => {
<> return isNoData() === true ? (
{isLoading ? ( <NoDataComponent text="No images" />
<Loading /> ) : (
) : ( <Stack alignItems="center" className={classes.gridWrapper}>
<Stack alignItems="center" className={classes.gridWrapper}> <Stack className={classes.sectionHeaderContainer} sx={{ paddingTop: '3rem' }}>
<Stack className={classes.sectionHeaderContainer} sx={{ paddingTop: '3rem' }}> <div>
<div> <Typography variant="h4" align="left" className={classes.sectionTitle}>
<Typography variant="h4" align="left" className={classes.sectionTitle}> Most popular images
Most popular images </Typography>
</Typography> </div>
</div> <div onClick={() => handleClickViewAll('sortby', sortByCriteria.downloads.value)}>
<div onClick={() => handleClickViewAll('sortby', sortByCriteria.downloads.value)}> <Typography variant="body2" className={classes.viewAll}>
<Typography variant="body2" className={classes.viewAll}> View all
View all </Typography>
</Typography> </div>
</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)}
</>
)}
</Stack> </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; export default Home;

View File

@ -312,7 +312,13 @@ export default function SignIn({ isLoggedIn, setIsLoggedIn, wrapperSetLoading =
Welcome back! Please login. Welcome back! Please login.
</Typography> </Typography>
{renderThirdPartyLoginMethods()} {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') && ( {Object.keys(authMethods).includes('htpasswd') && (
<Box component="form" onSubmit={null} noValidate autoComplete="off"> <Box component="form" onSubmit={null} noValidate autoComplete="off">
<TextField <TextField

View File

@ -9,12 +9,20 @@ import { isEmpty, uniq } from 'lodash';
import { api, endpoints } from '../../api'; import { api, endpoints } from '../../api';
import { host } from '../../host'; import { host } from '../../host';
import { useParams, useNavigate, createSearchParams } from 'react-router-dom'; import { useParams, useNavigate, createSearchParams } from 'react-router-dom';
import { mapToRepoFromRepoInfo } from 'utilities/objectModels';
import { isAuthenticated } from 'utilities/authUtilities';
// components // components
import { Card, CardContent, CardMedia, Chip, Grid, Stack, Tooltip, Typography, IconButton } from '@mui/material'; import { Card, CardContent, CardMedia, Chip, Grid, Stack, Tooltip, Typography, IconButton } from '@mui/material';
import BookmarkIcon from '@mui/icons-material/Bookmark'; import BookmarkIcon from '@mui/icons-material/Bookmark';
import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder'; 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 // placeholder images
import repocube1 from '../../assets/repocube-1.png'; import repocube1 from '../../assets/repocube-1.png';
@ -22,13 +30,7 @@ import repocube2 from '../../assets/repocube-2.png';
import repocube3 from '../../assets/repocube-3.png'; import repocube3 from '../../assets/repocube-3.png';
import repocube4 from '../../assets/repocube-4.png'; import repocube4 from '../../assets/repocube-4.png';
import Tags from './Tabs/Tags.jsx'; import makeStyles from '@mui/styles/makeStyles';
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';
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
pageWrapper: { pageWrapper: {
@ -195,6 +197,10 @@ function RepoDetails() {
}; };
}, [name]); }, [name]);
const handleDeleteTag = (removed) => {
setTags((prevState) => prevState.filter((tag) => tag.tag !== removed));
};
const handlePlatformChipClick = (event) => { const handlePlatformChipClick = (event) => {
const { textContent } = event.target; const { textContent } = event.target;
event.stopPropagation(); event.stopPropagation();
@ -221,7 +227,7 @@ function RepoDetails() {
const handleBookmarkClick = () => { const handleBookmarkClick = () => {
api.put(`${host()}${endpoints.bookmarkToggle(name)}`, abortController.signal).then((response) => { api.put(`${host()}${endpoints.bookmarkToggle(name)}`, abortController.signal).then((response) => {
if (response.status === 200) { if (response && response.status === 200) {
setRepoDetailData((prevState) => ({ setRepoDetailData((prevState) => ({
...prevState, ...prevState,
isBookmarked: !prevState.isBookmarked isBookmarked: !prevState.isBookmarked
@ -230,6 +236,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 = () => { const getVendor = () => {
return `${repoDetailData.newestTag?.Vendor || 'Vendor not available'}`; return `${repoDetailData.newestTag?.Vendor || 'Vendor not available'}`;
}; };
@ -276,15 +293,26 @@ function RepoDetails() {
signatureInfo={repoDetailData.signatureInfo} signatureInfo={repoDetailData.signatureInfo}
/> />
</Stack> </Stack>
{isAuthenticated() && ( <Stack alignItems="center" sx={{ width: { xs: '100%', md: 'auto' } }} direction="row" spacing={1}>
<IconButton component="span" onClick={handleBookmarkClick} data-testid="bookmark-button"> {isAuthenticated() && (
{repoDetailData?.isBookmarked ? ( <IconButton component="span" onClick={handleStarClick} data-testid="star-button">
<BookmarkIcon data-testid="bookmarked" /> {repoDetailData?.isStarred ? (
) : ( <StarIcon data-testid="starred" />
<BookmarkBorderIcon data-testid="not-bookmarked" /> ) : (
)} <StarBorderIcon data-testid="not-starred" />
</IconButton> )}
)} </IconButton>
)}
{isAuthenticated() && (
<IconButton component="span" onClick={handleBookmarkClick} data-testid="bookmark-button">
{repoDetailData?.isBookmarked ? (
<BookmarkIcon data-testid="bookmarked" />
) : (
<BookmarkBorderIcon data-testid="not-bookmarked" />
)}
</IconButton>
)}
</Stack>
</Stack> </Stack>
<Typography gutterBottom className={classes.repoTitle}> <Typography gutterBottom className={classes.repoTitle}>
{repoDetailData?.title || 'Title not available'} {repoDetailData?.title || 'Title not available'}
@ -317,7 +345,7 @@ function RepoDetails() {
<Grid item xs={12} md={8} className={classes.tags}> <Grid item xs={12} md={8} className={classes.tags}>
<Card className={classes.cardRoot}> <Card className={classes.cardRoot}>
<CardContent className={classes.tagsContent}> <CardContent className={classes.tagsContent}>
<Tags tags={tags} /> <Tags tags={tags} repoName={name} onTagDelete={handleDeleteTag} />
</CardContent> </CardContent>
</Card> </Card>
</Grid> </Grid>

View File

@ -43,7 +43,7 @@ const useStyles = makeStyles(() => ({
export default function Tags(props) { export default function Tags(props) {
const classes = useStyles(); const classes = useStyles();
const { tags } = props; const { tags, repoName, onTagDelete } = props;
const [tagsFilter, setTagsFilter] = useState(''); const [tagsFilter, setTagsFilter] = useState('');
const [sortFilter, setSortFilter] = useState(tagsSortByCriteria.updateTimeDesc.value); const [sortFilter, setSortFilter] = useState(tagsSortByCriteria.updateTimeDesc.value);
@ -63,6 +63,9 @@ export default function Tags(props) {
lastUpdated={tag.lastUpdated} lastUpdated={tag.lastUpdated}
vendor={tag.vendor} vendor={tag.vendor}
manifests={tag.manifests} 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 React, { useState } from 'react';
import makeStyles from '@mui/styles/makeStyles';
import transform from 'utilities/transform';
import { Card, CardContent, Typography, Grid, Divider, Stack, Collapse } from '@mui/material'; import { Card, CardContent, Typography, Grid, Divider, Stack, Collapse } from '@mui/material';
import { KeyboardArrowDown, KeyboardArrowRight } from '@mui/icons-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(() => ({ const useStyles = makeStyles(() => ({
card: { card: {

View File

@ -28,6 +28,8 @@ import {
import makeStyles from '@mui/styles/makeStyles'; import makeStyles from '@mui/styles/makeStyles';
import BookmarkIcon from '@mui/icons-material/Bookmark'; import BookmarkIcon from '@mui/icons-material/Bookmark';
import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder'; 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 { useTheme } from '@emotion/react';
// placeholder images // placeholder images
@ -183,17 +185,24 @@ function RepoCard(props) {
platforms, platforms,
description, description,
downloads, downloads,
stars,
isSigned, isSigned,
signatureInfo, signatureInfo,
lastUpdated, lastUpdated,
version, version,
vulnerabilityData, vulnerabilityData,
isBookmarked isBookmarked,
isStarred
} = props; } = props;
// keep a local bookmark state to display in the ui dynamically on updates // keep a local bookmark state to display in the ui dynamically on updates
const [currentBookmarkValue, setCurrentBookmarkValue] = useState(isBookmarked); 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 = () => { const goToDetails = () => {
navigate(`/image/${encodeURIComponent(name)}`); navigate(`/image/${encodeURIComponent(name)}`);
}; };
@ -215,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 platformChips = () => {
const filteredPlatforms = uniq(platforms?.flatMap((platform) => [platform.Os, platform.Arch])); const filteredPlatforms = uniq(platforms?.flatMap((platform) => [platform.Os, platform.Arch]));
const hiddenChips = filteredPlatforms.length - MAX_PLATFORM_CHIPS; const hiddenChips = filteredPlatforms.length - MAX_PLATFORM_CHIPS;
@ -260,6 +286,16 @@ 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>
)
);
};
return ( return (
<Card variant="outlined" className={classes.card} data-testid="repo-card"> <Card variant="outlined" className={classes.card} data-testid="repo-card">
<CardActionArea <CardActionArea
@ -337,6 +373,15 @@ function RepoCard(props) {
#1 #1
</Typography> </Typography>
</Grid> */} </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 container item xs={12} className={classes.contentRightActions}>
<Grid item>{renderBookmark()}</Grid> <Grid item>{renderBookmark()}</Grid>
</Grid> </Grid>

View File

@ -12,8 +12,8 @@ function SignatureTooltip({ isSigned, signatureInfo }) {
<Stack direction="column"> <Stack direction="column">
<Typography>{isSigned ? 'Verified Signature' : 'Unverified Signature'}</Typography> <Typography>{isSigned ? 'Verified Signature' : 'Unverified Signature'}</Typography>
<Typography>Tool: {tool}</Typography> <Typography>Tool: {tool}</Typography>
<Typography>Trusted: {isTrusted ? 'Yes' : 'No'}</Typography> <Typography>Trusted: {!isEmpty(isTrusted) ? isTrusted : 'Unknown'}</Typography>
<Typography>Author: {author}</Typography> <Typography>Author: {!isEmpty(author) ? author : 'Unknown'}</Typography>
</Stack> </Stack>
); );
} }

View File

@ -6,6 +6,7 @@ import { Markdown } from 'utilities/MarkdowntojsxWrapper';
import transform from 'utilities/transform'; import transform from 'utilities/transform';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { KeyboardArrowDown, KeyboardArrowRight } from '@mui/icons-material'; import { KeyboardArrowDown, KeyboardArrowRight } from '@mui/icons-material';
import DeleteTag from 'components/Shared/DeleteTag';
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
card: { card: {
@ -78,9 +79,9 @@ const useStyles = makeStyles((theme) => ({
})); }));
export default function TagCard(props) { 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 [open, setOpen] = useState(false);
const classes = useStyles(); const classes = useStyles();
const lastDate = lastUpdated const lastDate = lastUpdated
@ -99,9 +100,12 @@ export default function TagCard(props) {
return ( return (
<Card className={classes.card} raised> <Card className={classes.card} raised>
<CardContent className={classes.content}> <CardContent className={classes.content}>
<Typography variant="body1" align="left" className={classes.tagHeading}> <Stack direction="row" spacing={2} justifyContent="space-between">
Tag <Typography variant="body1" align="left" className={classes.tagHeading}>
</Typography> Tag
</Typography>
{isDeletable && <DeleteTag repo={repo} tag={tag} onTagDelete={onTagDelete} />}
</Stack>
<Typography variant="body1" align="left" className={classes.tagName} onClick={() => goToTags()}> <Typography variant="body1" align="left" className={classes.tagName} onClick={() => goToTags()}>
{repoName && `${repoName}:`} {repoName && `${repoName}:`}
{tag} {tag}

View File

@ -13,6 +13,7 @@ import { Link } from 'react-router-dom';
import { KeyboardArrowDown, KeyboardArrowRight } from '@mui/icons-material'; import { KeyboardArrowDown, KeyboardArrowRight } from '@mui/icons-material';
import { VulnerabilityChipCheck } from 'utilities/vulnerabilityAndSignatureCheck'; import { VulnerabilityChipCheck } from 'utilities/vulnerabilityAndSignatureCheck';
import { CVE_FIXEDIN_PAGE_SIZE } from 'utilities/paginationConstants'; import { CVE_FIXEDIN_PAGE_SIZE } from 'utilities/paginationConstants';
import VulnerabilityPackageSection from './VulnerabilityPackageSection';
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
card: { card: {
@ -29,18 +30,46 @@ const useStyles = makeStyles((theme) => ({
marginTop: '2rem', marginTop: '2rem',
marginBottom: '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: { content: {
textAlign: 'left', textAlign: 'left',
color: '#606060', color: '#606060',
padding: '2% 3% 2% 3%', padding: '2% 3% 2% 3%',
width: '100%' width: '100%'
}, },
contentCollapsed: {
textAlign: 'left',
color: '#606060',
padding: '1% 3% 1% 3%',
width: '100%',
'&:last-child': {
paddingBottom: '1%'
}
},
cveId: { cveId: {
color: theme.palette.primary.main, color: theme.palette.primary.main,
fontSize: '1rem', fontSize: '1rem',
fontWeight: 400, fontWeight: 400,
textDecoration: 'underline' textDecoration: 'underline'
}, },
cveIdCollapsed: {
color: theme.palette.primary.main,
fontSize: '0.75rem',
fontWeight: 500,
textDecoration: 'underline',
flexBasis: '19%'
},
cveSummary: { cveSummary: {
color: theme.palette.secondary.dark, color: theme.palette.secondary.dark,
fontSize: '0.75rem', fontSize: '0.75rem',
@ -48,6 +77,13 @@ const useStyles = makeStyles((theme) => ({
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
marginTop: '0.5rem' marginTop: '0.5rem'
}, },
cveSummaryCollapsed: {
color: theme.palette.secondary.dark,
fontSize: '0.75rem',
fontWeight: '600',
textOverflow: 'ellipsis',
flexBasis: '82%'
},
link: { link: {
color: '#52637A', color: '#52637A',
fontSize: '1rem', fontSize: '1rem',
@ -66,15 +102,21 @@ const useStyles = makeStyles((theme) => ({
cursor: 'pointer', cursor: 'pointer',
textAlign: 'center' textAlign: 'center'
}, },
dropdownCVE: {
color: '#1479FF',
cursor: 'pointer'
},
vulnerabilityCardDivider: { vulnerabilityCardDivider: {
margin: '1rem 0' margin: '1rem 0'
},
cveInfo: {
marginTop: '2%'
} }
})); }));
function VulnerabilitiyCard(props) { function VulnerabilitiyCard(props) {
const classes = useStyles(); const classes = useStyles();
const { cve, name, platform } = props; const { cve, name, platform, expand } = props;
const [openDesc, setOpenDesc] = useState(false); const [openCVE, setOpenCVE] = useState(expand);
const [openFixed, setOpenFixed] = useState(false);
const [loadingFixed, setLoadingFixed] = useState(true); const [loadingFixed, setLoadingFixed] = useState(true);
const [fixedInfo, setFixedInfo] = useState([]); const [fixedInfo, setFixedInfo] = useState([]);
const abortController = useMemo(() => new AbortController(), []); const abortController = useMemo(() => new AbortController(), []);
@ -82,9 +124,10 @@ function VulnerabilitiyCard(props) {
// pagination props // pagination props
const [pageNumber, setPageNumber] = useState(1); const [pageNumber, setPageNumber] = useState(1);
const [isEndOfList, setIsEndOfList] = useState(false); const [isEndOfList, setIsEndOfList] = useState(false);
const [loadMoreInfo, setLoadMoreInfo] = useState(false);
const getPaginatedResults = () => { const getPaginatedResults = () => {
if (!openFixed || isEndOfList) { if (!openCVE || (!loadMoreInfo && !isEmpty(fixedInfo)) || isEndOfList) {
return; return;
} }
setLoadingFixed(true); setLoadingFixed(true);
@ -107,11 +150,13 @@ function VulnerabilitiyCard(props) {
); );
} }
setLoadingFixed(false); setLoadingFixed(false);
setLoadMoreInfo(false);
}) })
.catch((e) => { .catch((e) => {
console.error(e); console.error(e);
setIsEndOfList(true); setIsEndOfList(true);
setLoadingFixed(false); setLoadingFixed(false);
setLoadMoreInfo(false);
}); });
}; };
@ -120,10 +165,15 @@ function VulnerabilitiyCard(props) {
return () => { return () => {
abortController.abort(); abortController.abort();
}; };
}, [openFixed, pageNumber]); }, [openCVE, pageNumber, loadMoreInfo]);
useEffect(() => {
setOpenCVE(expand);
}, [expand]);
const loadMore = () => { const loadMore = () => {
if (loadingFixed || isEndOfList) return; if (loadingFixed || isEndOfList) return;
setLoadMoreInfo(true);
setPageNumber((pageNumber) => pageNumber + 1); setPageNumber((pageNumber) => pageNumber + 1);
}; };
@ -163,27 +213,65 @@ function VulnerabilitiyCard(props) {
}; };
return ( return (
<Card className={classes.card} raised> <Card className={openCVE ? classes.card : classes.cardCollapsed} raised>
<CardContent className={classes.content}> <CardContent className={openCVE ? classes.content : classes.contentCollapsed}>
<Stack direction="row" spacing="1.25rem"> <Stack direction="row" spacing={openCVE ? '1.25rem' : '0.5rem'}>
<Typography variant="body1" align="left" className={classes.cveId}> {!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} {cve.id}
</Typography> </Typography>
<VulnerabilityChipCheck vulnerabilitySeverity={cve.severity} /> {openCVE ? (
</Stack> <VulnerabilityChipCheck vulnerabilitySeverity={cve.severity} />
<Typography variant="body1" align="left" className={classes.cveSummary}>
{cve.title}
</Typography>
<Divider className={classes.vulnerabilityCardDivider} />
<Stack className={classes.dropdown} onClick={() => setOpenFixed(!openFixed)}>
{!openFixed ? (
<KeyboardArrowRight className={classes.dropdownText} />
) : ( ) : (
<KeyboardArrowDown className={classes.dropdownText} /> <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> </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' }}> <Box sx={{ width: '100%', padding: '0.5rem 0' }}>
{loadingFixed ? ( {loadingFixed ? (
'Loading...' 'Loading...'
@ -194,16 +282,9 @@ function VulnerabilitiyCard(props) {
</Stack> </Stack>
)} )}
</Box> </Box>
</Collapse> <Typography variant="body2" align="left" className={classes.cveInfo}>
<Stack className={classes.dropdown} onClick={() => setOpenDesc(!openDesc)}> Description
{!openDesc ? ( </Typography>
<KeyboardArrowRight className={classes.dropdownText} />
) : (
<KeyboardArrowDown className={classes.dropdownText} />
)}
<Typography className={classes.dropdownText}>Description</Typography>
</Stack>
<Collapse in={openDesc} timeout="auto" unmountOnExit>
<Box sx={{ padding: '0.5rem 0' }}> <Box sx={{ padding: '0.5rem 0' }}>
<Typography variant="body2" align="left" sx={{ color: '#0F2139', fontSize: '1rem' }}> <Typography variant="body2" align="left" sx={{ color: '#0F2139', fontSize: '1rem' }}>
{cve.description} {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

@ -4,24 +4,55 @@ import React, { useEffect, useMemo, useState, useRef } from 'react';
import { api, endpoints } from '../../../api'; import { api, endpoints } from '../../../api';
// components // 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 makeStyles from '@mui/styles/makeStyles';
import { host } from '../../../host'; import { host } from '../../../host';
import { debounce, isEmpty } from 'lodash'; import { debounce, isEmpty } from 'lodash';
import Loading from '../../Shared/Loading'; import Loading from '../../Shared/Loading';
import { mapCVEInfo } from 'utilities/objectModels'; import { mapCVEInfo, mapAllCVEInfo } from 'utilities/objectModels';
import { EXPLORE_PAGE_SIZE } from 'utilities/paginationConstants'; import { EXPLORE_PAGE_SIZE } from 'utilities/paginationConstants';
import SearchIcon from '@mui/icons-material/Search'; 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 VulnerabilitiyCard from '../../Shared/VulnerabilityCard';
import VulnerabilityCountCard from '../../Shared/VulnerabilityCountCard';
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
searchAndDisplayBar: {
display: 'flex',
justifyContent: 'space-between'
},
title: { title: {
color: theme.palette.primary.main, color: theme.palette.primary.main,
fontSize: '1.5rem', fontSize: '1.5rem',
fontWeight: '600', fontWeight: '600',
marginBottom: '0' marginBottom: '0'
}, },
cveCountSummary: {
color: theme.palette.primary.main,
fontSize: '1.5rem',
fontWeight: '600',
marginBottom: '0'
},
cveId: { cveId: {
color: theme.palette.primary.main, color: theme.palette.primary.main,
fontSize: '1rem', fontSize: '1rem',
@ -40,9 +71,17 @@ const useStyles = makeStyles((theme) => ({
fontSize: '1.4rem', fontSize: '1.4rem',
fontWeight: '600' fontWeight: '600'
}, },
vulnerabilities: {
position: 'relative',
maxWidth: '100%',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between'
},
search: { search: {
position: 'relative', position: 'relative',
maxWidth: '100%', maxWidth: '100%',
flex: 0.95,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
@ -50,6 +89,20 @@ const useStyles = makeStyles((theme) => ({
border: '0.063rem solid #E7E7E7', border: '0.063rem solid #E7E7E7',
borderRadius: '0.625rem' borderRadius: '0.625rem'
}, },
expandableSearchInput: {
flexGrow: 0.95
},
view: {
alignContent: 'right',
variant: 'outlined'
},
viewModes: {
position: 'relative',
alignItems: 'baseline',
maxWidth: '100%',
flexDirection: 'row',
justifyContent: 'right'
},
searchIcon: { searchIcon: {
color: '#52637A', color: '#52637A',
paddingRight: '3%' paddingRight: '3%'
@ -65,22 +118,56 @@ const useStyles = makeStyles((theme) => ({
'&::placeholder': { '&::placeholder': {
opacity: '1' 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) { function VulnerabilitiesDetails(props) {
const classes = useStyles(); const classes = useStyles();
const [cveData, setCveData] = useState([]); const [cveData, setCveData] = useState([]);
const [allCveData, setAllCveData] = useState([]);
const [cveSummary, setCVESummary] = useState({});
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isLoadingAllCve, setIsLoadingAllCve] = useState(true);
const abortController = useMemo(() => new AbortController(), []); const abortController = useMemo(() => new AbortController(), []);
const { name, tag, digest, platform } = props; const { name, tag, digest, platform } = props;
const [openExcludeSearch, setOpenExcludeSearch] = useState(false);
// pagination props // pagination props
const [cveFilter, setCveFilter] = useState(''); const [cveFilter, setCveFilter] = useState('');
const [cveExcludeFilter, setCveExcludeFilter] = useState('');
const [cveSeverityFilter, setCveSeverityFilter] = useState('');
const [pageNumber, setPageNumber] = useState(1); const [pageNumber, setPageNumber] = useState(1);
const [isEndOfList, setIsEndOfList] = useState(false); const [isEndOfList, setIsEndOfList] = useState(false);
const listBottom = useRef(null); const listBottom = useRef(null);
const [anchorExport, setAnchorExport] = useState(null);
const openExport = Boolean(anchorExport);
const [selectedViewMore, setSelectedViewMore] = useState(false);
const getCVERequestName = () => { const getCVERequestName = () => {
return digest !== '' ? `${name}@${digest}` : `${name}:${tag}`; return digest !== '' ? `${name}@${digest}` : `${name}:${tag}`;
}; };
@ -91,16 +178,32 @@ function VulnerabilitiesDetails(props) {
`${host()}${endpoints.vulnerabilitiesForRepo( `${host()}${endpoints.vulnerabilitiesForRepo(
getCVERequestName(), getCVERequestName(),
{ pageNumber, pageSize: EXPLORE_PAGE_SIZE }, { pageNumber, pageSize: EXPLORE_PAGE_SIZE },
cveFilter cveFilter,
cveExcludeFilter,
cveSeverityFilter
)}`, )}`,
abortController.signal abortController.signal
) )
.then((response) => { .then((response) => {
if (response.data && response.data.data) { if (response.data && response.data.data) {
let cveInfo = response.data.data.CVEListForImage?.CVEList; let cveInfo = response.data.data.CVEListForImage?.CVEList;
let summary = response.data.data.CVEListForImage?.Summary;
let cveListData = mapCVEInfo(cveInfo); let cveListData = mapCVEInfo(cveInfo);
setCveData((previousState) => (pageNumber === 1 ? cveListData : [...previousState, ...cveListData])); setCveData((previousState) => (pageNumber === 1 ? cveListData : [...previousState, ...cveListData]));
setIsEndOfList(response.data.data.CVEListForImage.Page?.ItemCount < EXPLORE_PAGE_SIZE); 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) { } else if (response.data.errors) {
setIsEndOfList(true); setIsEndOfList(true);
} }
@ -110,10 +213,29 @@ function VulnerabilitiesDetails(props) {
console.error(e); console.error(e);
setIsLoading(false); setIsLoading(false);
setCveData([]); setCveData([]);
setCVESummary(() => {});
setIsEndOfList(true); 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 = () => { const resetPagination = () => {
setIsLoading(true); setIsLoading(true);
setIsEndOfList(false); setIsEndOfList(false);
@ -124,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 handleCveFilterChange = (e) => {
const { value } = e.target; const { value } = e.target;
setCveFilter(value); 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 debouncedChangeHandler = useMemo(() => debounce(handleCveFilterChange, 300));
const debouncedExcludeFilterChangeHandler = useMemo(() => debounce(handleCveExcludeFilterChange, 300));
useEffect(() => { useEffect(() => {
getPaginatedCVEs(); getPaginatedCVEs();
@ -163,25 +323,52 @@ function VulnerabilitiesDetails(props) {
useEffect(() => { useEffect(() => {
if (isLoading) return; if (isLoading) return;
resetPagination(); resetPagination();
}, [cveFilter]); }, [cveFilter, cveExcludeFilter, cveSeverityFilter]);
useEffect(() => { useEffect(() => {
return () => { return () => {
abortController.abort(); abortController.abort();
debouncedChangeHandler.cancel(); debouncedChangeHandler.cancel();
debouncedExcludeFilterChangeHandler.cancel();
}; };
}, []); }, []);
useEffect(() => {
if (openExport && isEmpty(allCveData)) {
getAllCVEs();
}
}, [openExport]);
const renderCVEs = () => { const renderCVEs = () => {
return !isEmpty(cveData) ? ( return !isEmpty(cveData) ? (
cveData.map((cve, index) => { cveData.map((cve, index) => {
return <VulnerabilitiyCard key={index} cve={cve} name={name} platform={platform} />; return <VulnerabilitiyCard key={index} cve={cve} name={name} platform={platform} expand={selectedViewMore} />;
}) })
) : ( ) : (
<div>{!isLoading && <Typography className={classes.none}> No Vulnerabilities </Typography>}</div> <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 = () => { const renderListBottom = () => {
if (isLoading) { if (isLoading) {
return <Loading />; return <Loading />;
@ -194,21 +381,112 @@ function VulnerabilitiesDetails(props) {
return ( return (
<Stack direction="column" spacing="1rem" data-testid="vulnerability-container"> <Stack direction="column" spacing="1rem" data-testid="vulnerability-container">
<Typography variant="h4" gutterBottom component="div" align="left" className={classes.title}> <Stack className={classes.vulnerabilities}>
Vulnerabilities <Typography variant="h4" gutterBottom component="div" align="left" className={classes.title}>
</Typography> Vulnerabilities
<Stack className={classes.search}> </Typography>
<InputBase <Stack direction="row" spacing="1rem" className={classes.viewModes}>
placeholder={'Search'} <IconButton disableRipple onClick={handleClickExport}>
classes={{ root: classes.searchInputBase, input: classes.input }} <DownloadIcon />
onChange={debouncedChangeHandler} </IconButton>
/> <Snackbar
<div className={classes.searchIcon}> open={openExport && isLoadingAllCve}
<SearchIcon /> message="Getting your data ready for export"
</div> 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> </Stack>
{renderCVEs()}
{renderListBottom()}
</Stack> </Stack>
); );
} }

View File

@ -3,7 +3,10 @@ import React, { useEffect, useMemo, useState, useRef } from 'react';
// utility // utility
import { api, endpoints } from '../../api'; import { api, endpoints } from '../../api';
import { host } from '../../host';
import { mapToImage } from '../../utilities/objectModels'; import { mapToImage } from '../../utilities/objectModels';
import { isEmpty, head } from 'lodash';
// components // components
import { import {
Card, Card,
@ -19,23 +22,21 @@ import {
Typography, Typography,
InputLabel InputLabel
} from '@mui/material'; } 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 makeStyles from '@mui/styles/makeStyles';
import { host } from '../../host';
// placeholder images // placeholder images
import repocube1 from '../../assets/repocube-1.png'; import repocube1 from '../../assets/repocube-1.png';
import repocube2 from '../../assets/repocube-2.png'; import repocube2 from '../../assets/repocube-2.png';
import repocube3 from '../../assets/repocube-3.png'; import repocube3 from '../../assets/repocube-3.png';
import repocube4 from '../../assets/repocube-4.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) => ({ const useStyles = makeStyles((theme) => ({
pageWrapper: { pageWrapper: {

View File

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

@ -5,6 +5,8 @@ import App from './App';
import reportWebVitals from './reportWebVitals'; import reportWebVitals from './reportWebVitals';
import { createTheme, ThemeProvider, StyledEngineProvider, adaptV4Theme } from '@mui/material/styles'; import { createTheme, ThemeProvider, StyledEngineProvider, adaptV4Theme } from '@mui/material/styles';
import { LocalizationProvider } from '@mui/x-date-pickers';
import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon';
const theme = createTheme( const theme = createTheme(
adaptV4Theme({ adaptV4Theme({
@ -36,7 +38,9 @@ ReactDOM.render(
<React.StrictMode> <React.StrictMode>
<StyledEngineProvider injectFirst> <StyledEngineProvider injectFirst>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<App /> <LocalizationProvider dateAdapter={AdapterLuxon}>
<App />
</LocalizationProvider>
</ThemeProvider> </ThemeProvider>
</StyledEngineProvider> </StyledEngineProvider>
</React.StrictMode>, </React.StrictMode>,

View File

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

View File

@ -0,0 +1,57 @@
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
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

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

View File

@ -6,6 +6,10 @@ const osFilters = [
{ {
label: 'linux', label: 'linux',
value: 'linux' value: 'linux'
},
{
label: 'freebsd',
value: 'freebsd'
} }
]; ];
@ -19,6 +23,11 @@ const imageFilters = [
label: 'Bookmarks', label: 'Bookmarks',
value: 'IsBookmarked', value: 'IsBookmarked',
type: 'boolean' type: 'boolean'
},
{
label: 'Starred Repositories',
value: 'IsStarred',
type: 'boolean'
} }
]; ];

View File

@ -15,6 +15,7 @@ const mapToRepo = (responseRepo) => {
logo: responseRepo.NewestImage?.Logo, logo: responseRepo.NewestImage?.Logo,
lastUpdated: responseRepo.LastUpdated, lastUpdated: responseRepo.LastUpdated,
downloads: responseRepo.DownloadCount, downloads: responseRepo.DownloadCount,
stars: responseRepo.StarCount,
vulnerabiltySeverity: responseRepo.NewestImage?.Vulnerabilities?.MaxSeverity, vulnerabiltySeverity: responseRepo.NewestImage?.Vulnerabilities?.MaxSeverity,
vulnerabilityCount: responseRepo.NewestImage?.Vulnerabilities?.Count vulnerabilityCount: responseRepo.NewestImage?.Vulnerabilities?.Count
}; };
@ -33,6 +34,7 @@ const mapToRepoFromRepoInfo = (responseRepoInfo) => {
title: responseRepoInfo.Summary?.NewestImage?.Title, title: responseRepoInfo.Summary?.NewestImage?.Title,
source: responseRepoInfo.Summary?.NewestImage?.Source, source: responseRepoInfo.Summary?.NewestImage?.Source,
downloads: responseRepoInfo.Summary?.NewestImage?.DownloadCount, downloads: responseRepoInfo.Summary?.NewestImage?.DownloadCount,
stars: responseRepoInfo.Summary?.NewestImage?.StarCount,
overview: responseRepoInfo.Summary?.NewestImage?.Documentation, overview: responseRepoInfo.Summary?.NewestImage?.Documentation,
license: responseRepoInfo.Summary?.NewestImage?.Licenses, license: responseRepoInfo.Summary?.NewestImage?.Licenses,
vulnerabilitySeverity: responseRepoInfo.Summary?.NewestImage?.Vulnerabilities?.MaxSeverity, vulnerabilitySeverity: responseRepoInfo.Summary?.NewestImage?.Vulnerabilities?.MaxSeverity,
@ -53,6 +55,7 @@ const mapToImage = (responseImage) => {
referrers: responseImage.Referrers, referrers: responseImage.Referrers,
size: responseImage.Size, size: responseImage.Size,
downloadCount: responseImage.DownloadCount, downloadCount: responseImage.DownloadCount,
starCount: responseImage.StarCount,
lastUpdated: responseImage.LastUpdated, lastUpdated: responseImage.LastUpdated,
description: responseImage.Description, description: responseImage.Description,
isSigned: responseImage.IsSigned, isSigned: responseImage.IsSigned,
@ -66,6 +69,7 @@ const mapToImage = (responseImage) => {
authors: responseImage.Authors, authors: responseImage.Authors,
vulnerabiltySeverity: responseImage.Vulnerabilities?.MaxSeverity, vulnerabiltySeverity: responseImage.Vulnerabilities?.MaxSeverity,
vulnerabilityCount: responseImage.Vulnerabilities?.Count, vulnerabilityCount: responseImage.Vulnerabilities?.Count,
isDeletable: responseImage.IsDeletable,
// frontend only prop to increase interop with Repo objects and code reusability // frontend only prop to increase interop with Repo objects and code reusability
name: `${responseImage.RepoName}:${responseImage.Tag}` name: `${responseImage.RepoName}:${responseImage.Tag}`
}; };
@ -79,6 +83,7 @@ const mapToManifest = (responseManifest) => {
size: responseManifest.Size, size: responseManifest.Size,
platform: responseManifest.Platform, platform: responseManifest.Platform,
downloadCount: responseManifest.DownloadCount, downloadCount: responseManifest.DownloadCount,
starCount: responseManifest.StarCount,
layers: responseManifest.Layers, layers: responseManifest.Layers,
history: responseManifest.History, history: responseManifest.History,
vulnerabilities: responseManifest.Vulnerabilities vulnerabilities: responseManifest.Vulnerabilities
@ -91,17 +96,43 @@ const mapCVEInfo = (cveInfo) => {
id: cve.Id, id: cve.Id,
severity: cve.Severity, severity: cve.Severity,
title: cve.Title, 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; 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) => { const mapSignatureInfo = (signatureInfo) => {
return signatureInfo return signatureInfo
? { ? {
tool: signatureInfo.Tool, tool: signatureInfo.Tool,
isTrusted: signatureInfo.IsTrusted, isTrusted: signatureInfo.IsTrusted?.toString(),
author: signatureInfo.Author author: signatureInfo.Author
} }
: { : {
@ -119,4 +150,4 @@ const mapReferrer = (referrer) => ({
annotations: referrer.Annotations?.map((annotation) => ({ key: annotation.Key, value: annotation.Value })) 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_POPULAR_PAGE_SIZE = 3;
const HOME_RECENT_PAGE_SIZE = 2; const HOME_RECENT_PAGE_SIZE = 2;
const HOME_BOOKMARKS_PAGE_SIZE = 2; const HOME_BOOKMARKS_PAGE_SIZE = 2;
const HOME_STARS_PAGE_SIZE = 2;
const CVE_FIXEDIN_PAGE_SIZE = 5; const CVE_FIXEDIN_PAGE_SIZE = 5;
export { export {
@ -13,5 +14,6 @@ export {
CVE_FIXEDIN_PAGE_SIZE, CVE_FIXEDIN_PAGE_SIZE,
HOME_POPULAR_PAGE_SIZE, HOME_POPULAR_PAGE_SIZE,
HOME_RECENT_PAGE_SIZE, HOME_RECENT_PAGE_SIZE,
HOME_BOOKMARKS_PAGE_SIZE HOME_BOOKMARKS_PAGE_SIZE,
HOME_STARS_PAGE_SIZE
}; };

View File

@ -145,13 +145,10 @@ const CriticalVulnerabilityIcon = ({ vulnerabilityStringTitle }) => {
const NoneVulnerabilityChip = () => { const NoneVulnerabilityChip = () => {
return ( return (
<Chip <Chip
label="No Vulnerability" label="None"
sx={{ backgroundColor: '#E8F5E9', color: '#388E3C', fontSize: '0.8125rem' }} sx={{ backgroundColor: '#E8F5E9', color: '#388E3C', fontSize: '0.8125rem' }}
variant="filled" variant="filled"
onDelete={() => { icon={<OutlinedBugIcon sx={{ color: '#388E3C!important' }} />}
return;
}}
deleteIcon={<OutlinedBugIcon sx={{ color: '#388E3C!important' }} />}
data-testid="none-vulnerability-chip" data-testid="none-vulnerability-chip"
/> />
); );
@ -159,13 +156,10 @@ const NoneVulnerabilityChip = () => {
const UnknownVulnerabilityChip = () => { const UnknownVulnerabilityChip = () => {
return ( return (
<Chip <Chip
label="Unknown Vulnerability" label="Unknown"
sx={{ backgroundColor: '#ECEFF1', color: '#52637A', fontSize: '0.8125rem' }} sx={{ backgroundColor: '#ECEFF1', color: '#52637A', fontSize: '0.8125rem' }}
variant="filled" variant="filled"
onDelete={() => { icon={<OutlinedBugIcon sx={{ color: '#52637A!important' }} />}
return;
}}
deleteIcon={<OutlinedBugIcon sx={{ color: '#52637A!important' }} />}
data-testid="unknown-vulnerability-chip" data-testid="unknown-vulnerability-chip"
/> />
); );
@ -176,10 +170,7 @@ const FailedScanChip = () => {
label="Failed to scan" label="Failed to scan"
sx={{ backgroundColor: '#848484', color: '#F6F7F9', fontSize: '0.8125rem' }} sx={{ backgroundColor: '#848484', color: '#F6F7F9', fontSize: '0.8125rem' }}
variant="filled" variant="filled"
onDelete={() => { icon={<SvgIcon component={failedScanBug} sx={{ color: '#F6F7F9!important' }} />}
return;
}}
deleteIcon={<SvgIcon component={failedScanBug} sx={{ color: '#F6F7F9!important' }} />}
data-testid="failed-vulnerability-chip" data-testid="failed-vulnerability-chip"
/> />
); );
@ -187,13 +178,10 @@ const FailedScanChip = () => {
const LowVulnerabilityChip = () => { const LowVulnerabilityChip = () => {
return ( return (
<Chip <Chip
label="Low Vulnerability" label="Low"
sx={{ backgroundColor: '#FFF3E0', color: '#FB8C00', fontSize: '0.8125rem' }} sx={{ backgroundColor: '#FFF3E0', color: '#FB8C00', fontSize: '0.8125rem' }}
variant="filled" variant="filled"
onDelete={() => { icon={<OutlinedBugIcon sx={{ color: '#FB8C00!important' }} />}
return;
}}
deleteIcon={<OutlinedBugIcon sx={{ color: '#FB8C00!important' }} />}
data-testid="low-vulnerability-chip" data-testid="low-vulnerability-chip"
/> />
); );
@ -201,13 +189,10 @@ const LowVulnerabilityChip = () => {
const MediumVulnerabilityChip = () => { const MediumVulnerabilityChip = () => {
return ( return (
<Chip <Chip
label="Medium Vulnerability" label="Medium"
sx={{ backgroundColor: '#FFF3E0', color: '#FB8C00', fontSize: '0.8125rem' }} sx={{ backgroundColor: '#FFF3E0', color: '#FB8C00', fontSize: '0.8125rem' }}
variant="filled" variant="filled"
onDelete={() => { icon={<FilledBugIcon sx={{ color: '#FB8C00!important' }} />}
return;
}}
deleteIcon={<FilledBugIcon sx={{ color: '#FB8C00!important' }} />}
data-testid="medium-vulnerability-chip" data-testid="medium-vulnerability-chip"
/> />
); );
@ -215,13 +200,10 @@ const MediumVulnerabilityChip = () => {
const HighVulnerabilityChip = () => { const HighVulnerabilityChip = () => {
return ( return (
<Chip <Chip
label="High Vulnerability" label="High"
sx={{ backgroundColor: '#FEEBEE', color: '#E53935', fontSize: '0.8125rem' }} sx={{ backgroundColor: '#FEEBEE', color: '#E53935', fontSize: '0.8125rem' }}
variant="filled" variant="filled"
onDelete={() => { icon={<OutlinedBugIcon sx={{ color: '#E53935!important' }} />}
return;
}}
deleteIcon={<OutlinedBugIcon sx={{ color: '#E53935!important' }} />}
data-testid="high-vulnerability-chip" data-testid="high-vulnerability-chip"
/> />
); );
@ -229,13 +211,10 @@ const HighVulnerabilityChip = () => {
const CriticalVulnerabilityChip = () => { const CriticalVulnerabilityChip = () => {
return ( return (
<Chip <Chip
label="Critical Vulnerability" label="Critical"
sx={{ backgroundColor: '#FEEBEE', color: '#E53935', fontSize: '0.8125rem' }} sx={{ backgroundColor: '#FEEBEE', color: '#E53935', fontSize: '0.8125rem' }}
variant="filled" variant="filled"
onDelete={() => { icon={<FilledBugIcon sx={{ color: '#E53935!important' }} />}
return;
}}
deleteIcon={<FilledBugIcon sx={{ color: '#E53935!important' }} />}
data-testid="critical-vulnerability-chip" data-testid="critical-vulnerability-chip"
/> />
); );

View File

@ -76,8 +76,14 @@ test.describe('explore page test', () => {
await expect(exploreFirst).toBeVisible({ timeout: 250000 }); await expect(exploreFirst).toBeVisible({ timeout: 250000 });
const windowsFilter = page.getByRole('checkbox', { name: 'windows' });
await linuxFilter.uncheck(); 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 }); await expect(exploreFirst).not.toBeVisible({ timeout: 250000 });
}); });
}); });

View File

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

View File

@ -17,15 +17,15 @@ const pageSizes = {
}; };
const endpoints = { 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) => 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) => globalSearch: (searchTerm, sortCriteria, pageNumber = 1, pageSize = 10) =>
`/v2/_zot/ext/search?query={GlobalSearch(query:%22${searchTerm}%22,%20requestedPage:%20{limit:${pageSize}%20offset:${ `/v2/_zot/ext/search?query={GlobalSearch(query:%22${searchTerm}%22,%20requestedPage:%20{limit:${pageSize}%20offset:${
10 * (pageNumber - 1) 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) => 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}}%20Vendor%20Licenses%20}}`
}; };
export { hosts, endpoints, sortCriteria, pageSizes }; export { hosts, endpoints, sortCriteria, pageSizes };