diff --git a/.gitignore b/.gitignore index 8c3f08523..1c44f6238 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,11 @@ build/ internal/mocks/*.go internal/api/graphql/graph/model/models_gen.go internal/api/graphql/graph/generated.go + +# python venv and compiled files +.venv/ +*.pyc +__pycache__/ + +# e2e reports +e2e/out/ diff --git a/e2e/Makefile b/e2e/Makefile new file mode 100644 index 000000000..32986cf07 --- /dev/null +++ b/e2e/Makefile @@ -0,0 +1,29 @@ +OUTPUT_DIR:=out +REPO_DIR:=`pwd` +E2E_RESULTS_DIR:=${OUTPUT_DIR}/e2e/results +E2E_RUNTIME_DIR:=${OUTPUT_DIR}/e2e/runtime +E2E_TESTS_DIR:=tests +E2E_PYTHON_VENV_DIR:=.venv +E2E_REQUIREMENTS_FILE:=e2e-requirements.txt + +INCLUDETAG_F:= +ifdef TAG + INCLUDETAG_F:=--include ${TAG} +endif + +test: clean + @mkdir -pv ${E2E_RESULTS_DIR} ${E2E_RUNTIME_DIR} + @sh -c "export PATH=\"\$$PATH:\$$HOME/go/bin\" && . ${E2E_PYTHON_VENV_DIR}/bin/activate && cd ${E2E_RUNTIME_DIR} && robot ${ROBOT_FLAGS} --pythonpath ${REPO_DIR}/tests ${INCLUDETAG_F} --outputdir ${REPO_DIR}/${E2E_RESULTS_DIR} ${REPO_DIR}/${E2E_TESTS_DIR};deactivate" + +clean: + @rm -fvr ${E2E_RESULTS_DIR} ${E2E_RUNTIME_DIR} + @rm -fvr ${OUTPUT_DIR} + +create-venv: + sh -c "python3 -m venv ${E2E_PYTHON_VENV_DIR} && . ${E2E_PYTHON_VENV_DIR}/bin/activate && pip install --upgrade pip && pip install -r ${E2E_REQUIREMENTS_FILE} && deactivate" + +rm-venv: + rm -fr ${E2E_PYTHON_VENV_DIR}/ + +robot-help: + @sh -c ". ${E2E_PYTHON_VENV_DIR}/bin/activate && robot -h && deactivate" diff --git a/e2e/e2e-requirements.txt b/e2e/e2e-requirements.txt new file mode 100644 index 000000000..360f97e23 --- /dev/null +++ b/e2e/e2e-requirements.txt @@ -0,0 +1,8 @@ +click >= 8.0 +pymysql >= 1.2.0 +robotframework >= 4.1.3 +robotframework-databaselibrary >= 2.4.1 +robotframework-pythonlibcore >= 4.4.1 +robotframework-requests >= 0.9.7 +robotframework-seleniumlibrary >= 6.9.0 +selenium >= 4.25.0 diff --git a/e2e/resources/backend.robot b/e2e/resources/backend.robot new file mode 100644 index 000000000..042b155b7 --- /dev/null +++ b/e2e/resources/backend.robot @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors +# SPDX-License-Identifier: Apache-2.0 + +*** Settings *** +Library RequestsLibrary + +*** Variables *** +${HEUREKA_BACKEND_URL} http://localhost:80 +${HEUREKA_BACKEND_GRAPHQL_ENDPOINT} /query + +*** Keywords *** +Backend health request is sent + GET ${HEUREKA_BACKEND_URL}/health diff --git a/e2e/resources/db.robot b/e2e/resources/db.robot new file mode 100644 index 000000000..68d4150e0 --- /dev/null +++ b/e2e/resources/db.robot @@ -0,0 +1,114 @@ +# SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors +# SPDX-License-Identifier: Apache-2.0 + +*** Settings *** +Library DatabaseLibrary +Library Process + +*** Variables *** +${DB_HOST} localhost +${DB_PORT} 3306 +${DB_NAME} heureka +${DB_USER_NAME} my_username +${DB_USER_PASSWORD} my_password +${DB_MIGRATIONS_DIR} ${CURDIR}/../../internal/database/mariadb/migrations +${MIGRATE_TOOL} migrate + +*** Keywords *** +Connection to database is established + Connect to database + ... pymysql + ... ${DB_NAME} + ... ${DB_USER_NAME} + ... ${DB_USER_PASSWORD} + ... ${DB_HOST} + ... ${DB_PORT} + Test teardown append Disconnect from database + +Database migration dirty bit should be ${bitval} + ${result}= Query + ... SELECT version, dirty FROM schema_migrations + Should not be empty ${result} + Should be equal as strings ${result[0][1]} ${bitval} + +User table should contain only systemuser + ${result}= Query + ... SELECT user_name FROM user; + Length should be ${result} 1 + Should be equal ${result[0][0]} systemuser + +All data tables should be empty + ${query}= Catenate + ... SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES + ... WHERE TABLE_TYPE = 'BASE TABLE' AND + ... TABLE_SCHEMA = 'heureka' AND + ... TABLE_NAME != 'schema_migrations' AND + ... TABLE_NAME != 'user' AND + ... TABLE_ROWS != 0; + ${result}= Query ${query} + Should be empty ${result} + +Feed database from gzip + [Arguments] ${gzip_path} ${db_user}=${DB_USER_NAME} ${db_password}=${DB_USER_PASSWORD} ${db_name}=${DB_NAME} ${db_host}=${DB_HOST} ${db_port}=${DB_PORT} + [Documentation] Streams a .sql.gz file directly into the specified MariaDB database. + + # Construct the native shell pipeline command + ${command}= Catenate + ... set -o pipefail; + ... gunzip -c "${gzip_path}" | + ... mariadb -u"${db_user}" -P"${db_port}" -p"${db_password}" -h"${db_host}" "${db_name}" + + # shell=True is mandatory to allow the pipe (|) character to function + ${result}= Run process ${command} shell=True stderr=STDOUT + + # Validate that the import executed successfully + Log ${result.stdout} + Should be equal as integers ${result.rc} 0 + ... Database import failed with error: ${result.stdout} + +Reload database schema + Connect to database + ... pymysql + ... ${DB_NAME} + ... ${DB_USER_NAME} + ... ${DB_USER_PASSWORD} + ... ${DB_HOST} + ... ${DB_PORT} + + Execute sql string DROP DATABASE IF EXISTS ${DB_NAME}; + Execute sql string CREATE DATABASE ${DB_NAME}; + + Disconnect from database + +Run database up migrations + [Arguments] ${db_user}=${DB_USER_NAME} ${db_password}=${DB_USER_PASSWORD} ${db_name}=${DB_NAME} ${db_host}=${DB_HOST} ${db_port}=${DB_PORT} + ${result}= Run process + ... ${MIGRATE_TOOL} + ... -path + ... ${DB_MIGRATIONS_DIR} + ... -database + ... 'mysql://${db_user}:${db_password}@tcp(${db_host}:${db_port})/${db_name}' + ... up + ... shell=True + ... stderr=STDOUT + Should be equal as integers ${result.rc} 0 + ... Database up migration failed with error: ${result.stdout} + +Run database down migrations + [Arguments] ${db_user}=${DB_USER_NAME} ${db_password}=${DB_USER_PASSWORD} ${db_name}=${DB_NAME} ${db_host}=${DB_HOST} ${db_port}=${DB_PORT} + ${result}= Run process + ... ${MIGRATE_TOOL} + ... -path + ... ${DB_MIGRATIONS_DIR} + ... -database + ... 'mysql://${db_user}:${db_password}@tcp(${db_host}:${db_port})/${db_name}' + ... down + ... -all + ... shell=True + ... stderr=STDOUT + Should be equal as integers ${result.rc} 0 + ... Database up migration failed with error: ${result.stdout} + +Clear database + Reload database schema + Run database up migrations diff --git a/e2e/resources/graphql.robot b/e2e/resources/graphql.robot new file mode 100644 index 000000000..b3721b4e0 --- /dev/null +++ b/e2e/resources/graphql.robot @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors +# SPDX-License-Identifier: Apache-2.0 + +*** Settings *** +Library RequestsLibrary + +*** Variables *** + +*** Keywords *** +Get all user names + [Documentation] Iterates through Heureka pages using nextPageAfter to compile a flat list of names. + Create Session heureka_session ${HEUREKA_BACKEND_URL} verify=True + + ${master_name_list}= Create list + ${has_next}= Set variable ${TRUE} + ${cursor}= Set variable ${NONE} + + ${query_string}= Catenate SEPARATOR=\n + ... query ($filter: UserFilter, $first: Int, $after: String) { + ... Users ( + ... filter: $filter, + ... first: $first, + ... after: $after + ... ) { + ... totalCount + ... pageInfo { + ... hasNextPage + ... nextPageAfter + ... } + ... edges { + ... node { + ... name + ... } + ... } + ... } + ... } + + + WHILE ${has_next} + ${empty_list}= Create List + ${user_filter}= Create dictionary userName=${empty_list} + + # Pass the dynamic cursor token as the $after variable + ${variables}= Create dictionary filter=${user_filter} first=${10} after=${cursor} + ${payload}= Create dictionary query=${query_string} variables=${variables} + ${headers}= Create dictionary Content-Type=application/json Accept=application/json + + ${response}= POST on session heureka_session ${HEUREKA_BACKEND_GRAPHQL_ENDPOINT} json=${payload} headers=${headers} + Status should be 200 ${response} + ${json_res}= Set variable ${response.json()} + + # 2. FIXED PARSING LOCATIONS: Extract variables using the correct nextPageAfter map pointer + ${has_next}= Set variable ${json_res['data']['Users']['pageInfo']['hasNextPage']} + ${cursor}= Set variable ${json_res['data']['Users']['pageInfo']['nextPageAfter']} + + # Append this page's chunk straight to the master array tracking list + ${edges}= Set variable ${json_res['data']['Users']['edges']} + FOR ${edge} IN @{edges} + Append to list ${master_name_list} ${edge['node']['name']} + END + END + + RETURN ${master_name_list} + +Create service + [Arguments] ${ccrn} ${domain} ${region} + Create Session heureka_session ${HEUREKA_BACKEND_URL} verify=True + + ${mutation_string}= Catenate SEPARATOR=\n + ... mutation ($input: ServiceInput!) { + ... createService ( + ... input: $input + ... ) { + ... id + ... ccrn + ... domain + ... region + ... } + ... } + + ${service_input}= Create dictionary ccrn=${ccrn} domain=${domain} region=${region} + + ${variables}= Create dictionary input=${service_input} + ${payload}= Create dictionary query=${mutation_string} variables=${variables} + ${headers}= Create dictionary Content-Type=application/json Accept=application/json + + ${response}= POST on session heureka_session ${HEUREKA_BACKEND_GRAPHQL_ENDPOINT} json=${payload} headers=${headers} + Status should be 200 ${response} + ${json_res}= Set variable ${response.json()} + + Return from keyword ${json_res['data']['createService']['id']} diff --git a/e2e/resources/shadow.robot b/e2e/resources/shadow.robot new file mode 100644 index 000000000..d281414f6 --- /dev/null +++ b/e2e/resources/shadow.robot @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors +# SPDX-License-Identifier: Apache-2.0 + +*** Settings *** +Library SeleniumLibrary + +*** Keywords *** +Wait until shadow element is visible + [Arguments] ${selector} ${timeout}=10s + Wait until keyword succeeds ${timeout} 500ms + ... Shadow element should exist ${selector} + +Wait for shadow element + [Arguments] ${selector} ${timeout}=10s + ${element}= Wait until keyword succeeds ${timeout} 500ms + ... Get shadow element ${selector} + Return from keyword ${element} + +Shadow element should exist + [Arguments] ${selector} + + ${found}= Execute javascript + ... return document.querySelector('[data-shadow-host="true"]').shadowRoot.querySelector('${selector}') !== null + + Should Be True ${found} + +Get shadow element + [Arguments] ${selector} + ${element}= Execute javascript + ... return document.querySelector('[data-shadow-host="true"]').shadowRoot.querySelector('${selector}') + Run keyword if ${{ $element is None }} Fail Element ('${selector}') not found + Return from keyword ${element} diff --git a/e2e/resources/teardown.robot b/e2e/resources/teardown.robot new file mode 100644 index 000000000..7a4262a70 --- /dev/null +++ b/e2e/resources/teardown.robot @@ -0,0 +1,87 @@ +# SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors +# SPDX-License-Identifier: Apache-2.0 + +*** Settings *** +Library Collections + +*** Keywords *** +Test teardown init + [Documentation] Initialize a list of teardown actions which can be executed by 'Test teardown run' automatically as long as it is put into Test teardown field. Run this init in setup or case steps + Variable should not exist ${test_teardown_stack} Test teardown init: test_teardown_stack already defined!!! + @{test_teardown_stack} Create list + Set test variable ${test_teardown_stack} + +Test teardown run + [Documentation] Get list objects from list '*${test_teardown_stack}*', and then execute these list objects (keyword and its arguments) one by one. Failure will be ignored so all list object can be executed. + ${status} Run keyword and return status Variable should exist ${test_teardown_stack} Test teardown run: test_teardown_stack not defined!!! Run 'Test teardown init' in setup or case. + Return from keyword if ${status} != True + Log ============== Test teardown run start =============== + FOR ${item} IN @{test_teardown_stack} + ${status} ${return_value} Run keyword and ignore error @{item} + Log ${item[0]} status:::${status}, return:::${return_value} + END + Log ============== Test teardown run end =============== + +Test teardown append + [Arguments] @{keyword_and_args} + [Documentation] *@{keyword_and_args}*: the list of keyword and its arguments + Variable should exist ${test_teardown_stack} Test teardown add: test_teardown_stack not defined!!! Run 'Test teardown init' in setup or case. + @{kw_args} Create list @{keyword_and_args} + Append To List ${test_teardown_stack} ${kw_args} + +Test teardown add head + [Arguments] @{keyword_and_args} + [Documentation] *@{keyword_and_args}*: the list of keyword and its arguments + Variable should exist ${test_teardown_stack} Test teardown add head: test_teardown_stack not defined!!! Run 'Test teardown init' in setup or case. + @{kw_args} Create list @{keyword_and_args} + Insert into list ${test_teardown_stack} 0 ${kw_args} + +Test teardown insert + [Arguments] ${insert_position}=0 @{keyword_and_args} + [Documentation] *${insert_position}*: like 0 (the first), -1(the reversed second), -2(the reversed third), and so on. + ... + ... *@{keyword_and_args}*: the list of keyword and its arguments + Variable should exist ${test_teardown_stack} Test teardown insert: test_teardown_stack not defined!!! Run 'Test teardown init' in setup or case. + @{kw_args} Create list @{keyword_and_args} + Insert into list ${test_teardown_stack} ${insert_position} ${kw_args} + +Keyword teardown init + [Documentation] Initialize a list of teardown actions which can be executed by 'Keyword Teardown Run' automatically as long as it is put into Keyword's Teardown field. Run this init in keyword steps + #Variable should not exist ${keyword_teardown_queue} Keyword teardown init: keyword_teardown_stack already defined!!! + @{keyword_teardown_queue} Create list + Set test variable ${keyword_teardown_queue} + +Keyword teardown run + [Documentation] Get list objects from list '*${keyword_teardown_stack}*', and then execute these list objects (keyword and its arguments) one by one. Failure will be ignored so all list object can be executed. + ${status} Run keyword and return status Variable should exist ${keyword_teardown_queue} Keyword teardown Run: keyword_teardown_queue not defined!!! Run 'Keyword teardown init' in setup or case. + Return from keyword if ${status} != True + Log ============== Test teardown run start =============== + FOR ${item} IN @{keyword_teardown_queue} + ${status} ${return_value} Run keyword and ignore error @{item} + Log ${item[0]} status:::${status}, return:::${return_value} + END + Log ============== Test teardown run end =============== + @{keyword_teardown_queue} Create list + +Keyword teardown add head + [Arguments] @{keyword_and_args} + [Documentation] *@{keyword_and_args}*: the list of keyword and its arguments + Variable should exist ${keyword_teardown_queue} Keyword teardown add head: keyword_teardown_queue \ not defined!!! Run 'Keyword teardown init' in setup or case. + @{kw_args} Create list @{keyword_and_args} + Insert Into List ${keyword_teardown_queue} 0 ${kw_args} + +Keyword teardown append + [Arguments] @{keyword_and_args} + [Documentation] *@{keyword_and_args}*: the list of keyword and its arguments + Variable should exist ${keyword_teardown_queue} Keyword teardown append: keyword_teardown_queue not defined!!! Run 'Keyword teardown init' in setup or case. + @{kw_args} Create list @{keyword_and_args} + Append To List ${keyword_teardown_queue} ${kw_args} + +Keyword teardown insert + [Arguments] ${insert_position}=0 @{keyword_and_args} + [Documentation] *${insert_position}*: like 0 (the first), -1(the reversed second), -2(the reversed third), and so on. + ... + ... *@{keyword_and_args}*: the list of keyword and its arguments + Variable should exist ${keyword_teardown_queue} Keyword teardown insert: keyword_teardown_queue \ not defined!!! Run 'Keyword teardown init' in setup or case. + @{kw_args} Create list @{keyword_and_args} + Insert into list ${keyword_teardown_queue} ${insert_position} ${kw_args} diff --git a/e2e/resources/ui.robot b/e2e/resources/ui.robot new file mode 100644 index 000000000..e2b00a4a2 --- /dev/null +++ b/e2e/resources/ui.robot @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors +# SPDX-License-Identifier: Apache-2.0 + +*** Settings *** +Library SeleniumLibrary + +Resource shadow.robot + +*** Variables *** +${HEUREKA_UI_URL} http://localhost:3000 + +${BROWSER} headlessfirefox +#${BROWSER} firefox + +*** Keywords *** +Open browser to Heureka UI + Open browser ${HEUREKA_UI_URL} ${BROWSER} + Maximize browser window + Test teardown append Close Browser + +Wait for heureka UI logo + Wait until shadow element is visible [data-testid="default-logo"] + +Heureka UI is opened + Open browser to Heureka UI + Wait for Heureka UI logo diff --git a/e2e/tests/smoke.robot b/e2e/tests/smoke.robot new file mode 100644 index 000000000..24720fa19 --- /dev/null +++ b/e2e/tests/smoke.robot @@ -0,0 +1,72 @@ +# SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors +# SPDX-License-Identifier: Apache-2.0 + +*** Settings *** +Library RequestsLibrary + +Resource ../resources/backend.robot +Resource ../resources/db.robot +Resource ../resources/graphql.robot +Resource ../resources/teardown.robot +Resource ../resources/ui.robot + +Force tags smoke + +Test setup Test teardown init +Test teardown Test teardown run + +*** Variables *** +${VULNERABILITY_MSG_SELECTOR} div[class*="juno-stack"][class*="jn:flex"]:not(:has(button)):not(:has(input)) > div[class*="juno-stack"][class*="jn:flex"] +${SERVICE_MSG_SELECTOR} div[class*="juno-datagrid-cell"][role*="gridcell"] +${FILTER_KEY_SELECTOR} span[class*="pill-key"] +${FILTER_VALUE_SELECTOR} span[class*="pill-value"] + +*** Keywords *** +No vulnerabilities are found + ${element}= Wait for shadow element ${VULNERABILITY_MSG_SELECTOR} timeout=10s + ${actual_text}= Get text ${element} + Should contain ${actual_text} No vulnerabilities found! + +No service matching criteria are found + ${element}= Wait for shadow element ${SERVICE_MSG_SELECTOR} timeout=10s + ${actual_text}= Get text ${element} + Should contain ${actual_text} No service found + +Location should be changed to filter Services using SupportGroupCcrn + Wait Until Location Contains /services?f_supportGroupCcrn=containers timeout=10s + +Service tab with SupportGroupCcrn filter is visible + ${key_element}= Wait for shadow element ${FILTER_KEY_SELECTOR} timeout=10s + ${key_text}= Get text ${key_element} + Should contain ${key_text} supportGroupCcrn + + ${value_element}= Wait for shadow element ${FILTER_VALUE_SELECTOR} timeout=10s + ${value_text}= Get text ${value_element} + Should contain ${value_text} containers + +*** Test Cases *** +Heureka UI is operational + Given Open browser to Heureka UI + When Wait for Heureka UI logo + Then Title should be Heureka + +Heureka Backend is healthy + When Backend health request is sent + Then Status should be 200 + +Database is available + When Connection to database is established + Then Database migration dirty bit should be 0 + +Database schema is empty + When Connection to database is established + Then User table should contain only systemuser + And All data tables should be empty + +Heureka UI shows empty database on start screen + Given Clear database + When Heureka UI is opened + Then Location should be changed to filter Services using SupportGroupCcrn + And Service tab with SupportGroupCcrn filter is visible + And No vulnerabilities are found + And No service matching criteria are found diff --git a/ui/Dockerfile b/ui/Dockerfile index fb2ef4d31..78f1c1e80 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Greenhouse contributors # SPDX-License-Identifier: Apache-2.0 -FROM node:20-bullseye AS builder +FROM node:22.13-bullseye AS builder # Install jq and pnpm RUN apt-get update && apt-get install -y jq git && \ @@ -33,7 +33,7 @@ RUN cd "$UI_GIT_DIR" && \ pnpm run build # Runtime stage -FROM node:20-bullseye +FROM node:22.13-bullseye RUN npm install -g pnpm turbo # Set Runtime environment variables