From b5fcddde37589be85c73e16f01261d520fc425d6 Mon Sep 17 00:00:00 2001 From: lcard Date: Wed, 23 Aug 2023 13:42:52 +0100 Subject: [PATCH 01/17] First of many --- .github/.github.env | 2 + .github/workflows/dev.yml | 4 +- .github/workflows/main.yml | 6 +- Makefile | 4 +- api/Makefile | 4 +- docs/infrastructure/deployment.md | 5 -- infrastructure/blocks/pipeline-ami/data.tf | 9 +++ .../pipeline-ami/install.sh} | 22 ------- infrastructure/blocks/pipeline-ami/packer.tf | 25 ++++++++ .../blocks/pipeline-ami/provider.tf | 6 ++ .../blocks/pipeline-ami/template.json | 58 +++++++++++++++++++ .../blocks/pipeline-ami/variables.tf | 33 +++++++++++ infrastructure/blocks/pipeline/data.tf | 35 +++++++++++ infrastructure/blocks/pipeline/iam.tf | 29 ++-------- .../pipeline/initialisation-script.sh.tpl | 20 +++++++ infrastructure/blocks/pipeline/main.tf | 50 +++++----------- infrastructure/blocks/pipeline/provider.tf | 5 ++ infrastructure/blocks/pipeline/variables.tf | 10 ++++ infrastructure/scripts/env_setup.sh | 4 -- infrastructure/scripts/infra_make_helper.sh | 20 ------- 20 files changed, 231 insertions(+), 120 deletions(-) create mode 100644 infrastructure/blocks/pipeline-ami/data.tf rename infrastructure/{scripts/initialisation-script.sh.tpl => blocks/pipeline-ami/install.sh} (53%) create mode 100644 infrastructure/blocks/pipeline-ami/packer.tf create mode 100644 infrastructure/blocks/pipeline-ami/provider.tf create mode 100644 infrastructure/blocks/pipeline-ami/template.json create mode 100644 infrastructure/blocks/pipeline-ami/variables.tf create mode 100644 infrastructure/blocks/pipeline/data.tf create mode 100644 infrastructure/blocks/pipeline/initialisation-script.sh.tpl create mode 100644 infrastructure/blocks/pipeline/provider.tf delete mode 100644 infrastructure/scripts/env_setup.sh diff --git a/.github/.github.env b/.github/.github.env index 97289f3..ad8c623 100644 --- a/.github/.github.env +++ b/.github/.github.env @@ -2,3 +2,5 @@ COGNITO_USER_POOL_ID=rapid-pool RESOURCE_PREFIX=rapid ALLOWED_EMAIL_DOMAINS=example1.com,example2.com LAYERS=raw,layer +DOMAIN_NAME=example.com +DATA_BUCKET=the-bucket diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 1278154..3835c6e 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -45,8 +45,6 @@ jobs: - name: Populate .env with additional vars run: | cp ./.github/.github.env .env - echo DOMAIN_NAME=${{ secrets.DOMAIN_NAME }} >> .env - echo DATA_BUCKET=${{ secrets.DATA_BUCKET }} >> .env echo AWS_ACCOUNT=${{ secrets.AWS_ACCOUNT }} >> .env echo AWS_REGION=${{ secrets.AWS_REGION }} >> .env echo AWS_DEFAULT_REGION=${{ secrets.AWS_REGION }} >> .env @@ -76,7 +74,7 @@ jobs: run: | echo "TWINE_USERNAME=${{ secrets.TWINE_USERNAME_TEST }}" >> .env echo "TWINE_PASSWORD=${{ secrets.TWINE_PASSWORD_TEST }}" >> .env - echo TWINE_NON_INTERACTIVE=${{ secrets.TWINE_NON_INTERACTIVE }} >> .env + echo "TWINE_NON_INTERACTIVE=true" >> .env - name: Setup Python uses: actions/setup-python@v4 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1f5bfe3..65a0807 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -44,8 +44,6 @@ jobs: - name: Populate .env with additional vars run: | cp ./.github/.github.env .env - echo DOMAIN_NAME=${{ secrets.DOMAIN_NAME }} >> .env - echo DATA_BUCKET=${{ secrets.DATA_BUCKET }} >> .env echo AWS_ACCOUNT=${{ secrets.AWS_ACCOUNT }} >> .env echo AWS_REGION=${{ secrets.AWS_REGION }} >> .env echo AWS_DEFAULT_REGION=${{ secrets.AWS_REGION }} >> .env @@ -69,7 +67,7 @@ jobs: run: make api-tag-prod-candidate - name: API Deploy Image to Prod - run: make api-tag-live-in-prod + run: make api-app-live-in-prod - name: API Allow for Application to Start run: sleep 120 @@ -81,6 +79,8 @@ jobs: - name: API E2E Tests id: e2e-tests env: + DOMAIN_NAME: ${{ secrets.DOMAIN_NAME }} + DATA_BUCKET: ${{ secrets.DATA_BUCKET }} COGNITO_USER_POOL_ID: ${{ secrets.COGNITO_USER_POOL_ID }} RESOURCE_PREFIX: ${{ secrets.RESOURCE_PREFIX }} ALLOWED_EMAIL_DOMAINS: ${{ secrets.ALLOWED_EMAIL_DOMAINS }} diff --git a/Makefile b/Makefile index 8b2d897..6e11259 100644 --- a/Makefile +++ b/Makefile @@ -88,8 +88,8 @@ api-tag-and-upload-release-image:## Tag and upload the api release image api-tag-prod-candidate: ## Tag the uploaded api image as a candidate for PROD deployment @cd api/; $(MAKE) tag-prod-candidate -api-tag-live-in-prod: ## Deploy the latest version of the api - @cd api/; $(MAKE) tag-live-in-prod +api-app-live-in-prod: ## Deploy the latest version of the api + @cd api/; $(MAKE) app-live-in-prod api-check-app-is-running: @cd api/; $(MAKE) check-app-is-running diff --git a/api/Makefile b/api/Makefile index 4aa6398..7b03249 100644 --- a/api/Makefile +++ b/api/Makefile @@ -1,5 +1,5 @@ -ECS_SERVICE=rapid-ecs-service -ECS_CLUSTER=rapid-cluster +ECS_SERVICE=rapid-preprod-ecs-service +ECS_CLUSTER=rapid-preprod-cluster LATEST_COMMIT_HASH=$(shell git rev-parse --short HEAD) ACCOUNT_ECR_URI=$(AWS_ACCOUNT).dkr.ecr.$(AWS_REGION).amazonaws.com IMAGE_NAME=data-f1-registry diff --git a/docs/infrastructure/deployment.md b/docs/infrastructure/deployment.md index 0266808..c2c67d7 100644 --- a/docs/infrastructure/deployment.md +++ b/docs/infrastructure/deployment.md @@ -74,9 +74,6 @@ Our infrastructure is built using AWS, so you'll need an AWS account, and access Follow these steps to set up the AWS profile: - [Install/Update AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) -- [Set up a named profile](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-profiles.html) if you already have the AWS cli. - -After setting up the named profile, the current session can be checked by running ```aws sts get-caller-identity```. We have a file (`scripts/env_setup.sh) with the required exports to use the 'gov' profile. These exports have to be run when starting a new session. We use `jq` in our scripts to help the `make` targets work correctly, please [Install jq](https://stedolan.github.io/jq/download/) before running any make command. @@ -184,8 +181,6 @@ In order to gain the admin privileges necessary for infrastructure changes one n enabled only for user's defined in `input-params.tfvars`, only after logging into the AWS console for the first time as an IAM user and enabling MFA. -Then, to assume the role, set up the profile (`scripts/env_setup.sh`), run ```make infra-assume-role``` and follow the prompts. - ### Deploying remaining infra-blocks Once the state backend has been configured, provide/change the following inputs in `input-params.tfvars`. diff --git a/infrastructure/blocks/pipeline-ami/data.tf b/infrastructure/blocks/pipeline-ami/data.tf new file mode 100644 index 0000000..603ec83 --- /dev/null +++ b/infrastructure/blocks/pipeline-ami/data.tf @@ -0,0 +1,9 @@ +data "terraform_remote_state" "vpc-state" { + backend = "s3" + workspace = "prod" + + config = { + key = "vpc/terraform.tfstate" + bucket = var.state_bucket + } +} diff --git a/infrastructure/scripts/initialisation-script.sh.tpl b/infrastructure/blocks/pipeline-ami/install.sh similarity index 53% rename from infrastructure/scripts/initialisation-script.sh.tpl rename to infrastructure/blocks/pipeline-ami/install.sh index 73a1d86..020b8ed 100644 --- a/infrastructure/scripts/initialisation-script.sh.tpl +++ b/infrastructure/blocks/pipeline-ami/install.sh @@ -1,5 +1,3 @@ -#!/usr/bin/env bash - # Enable SSM sudo snap install amazon-ssm-agent --classic sudo snap start amazon-ssm-agent @@ -38,23 +36,3 @@ sudo apt install gh -y curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" unzip awscliv2.zip sudo ./aws/install - -# ---- Start docker service -sudo service docker start - -# ---- Allow ubuntu user to manage Docker service -sudo usermod -a -G docker ubuntu - -# Install GitHub Actions Runner -# Need to run these commands as the ubuntu user for correct permissions -sudo -u ubuntu mkdir /home/ubuntu/actions-runner -cd /home/ubuntu/actions-runner -sudo -u ubuntu curl -o actions-runner-linux-x64-2.307.1.tar.gz -L https://github.com/actions/runner/releases/download/v2.307.1/actions-runner-linux-x64-2.307.1.tar.gz -sudo -u ubuntu tar xzf ./actions-runner-linux-x64-2.307.1.tar.gz -sudo -u ubuntu ./config.sh --url https://github.com/no10ds --token "${runner-registration-token}" --name Data-F1-Pipeline-Runner --unattended --replace - -# Run the GitHub Actions Runner -sudo -u ubuntu ./run.sh & - -# # Configure the GitHub Actions Runner to start on reboot -sudo crontab -l -u ubuntu | echo "@reboot sudo -u ubuntu /home/ubuntu/actions-runner/run.sh &" | sudo crontab -u ubuntu - \ No newline at end of file diff --git a/infrastructure/blocks/pipeline-ami/packer.tf b/infrastructure/blocks/pipeline-ami/packer.tf new file mode 100644 index 0000000..3b24632 --- /dev/null +++ b/infrastructure/blocks/pipeline-ami/packer.tf @@ -0,0 +1,25 @@ +resource "null_resource" "packer_build" { + triggers = { + sha256_ami_config = filesha256("${path.module}/template.json") + sha256_ami_install = filesha256("${path.module}/install.sh") + version = var.pipeline_ami_version + } + + provisioner "local-exec" { + command = < Date: Wed, 23 Aug 2023 13:56:31 +0100 Subject: [PATCH 02/17] Fix --- .github/workflows/ui-tests.yml | 7 ++----- requirements.txt | 10 +++++----- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index dd280c9..e40849b 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -1,9 +1,6 @@ name: rAPId Integration Tests on: - push: - branches: - - "**" workflow_dispatch: @@ -29,11 +26,11 @@ jobs: npm install - name: Install playwright browsers - run: npx playwright install-deps && npx playwright install + run: npm install @playwright/test -D - name: run playwright tests run: npx playwright test ui/playwright env: - DOMAIN: ${{ secrets.DOMAIN }} + DOMAIN: "https://${{ secrets.DOMAIN }}" RESOURCE_PREFIX: ${{ secrets.RESOURCE_PREFIX }} AWS_REGION: ${{ secrets.AWS_REGION }} diff --git a/requirements.txt b/requirements.txt index a800c2c..1a91b58 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -bandit==1.7.5 -detect-secrets==1.4.0 -mkdocs==1.5.1 -mkdocs-material==9.1.21 -mkdocstrings[python]==1.2.1 +bandit +detect-secrets +mkdocs +mkdocs-material +mkdocstrings[python] From d9cf7fe6efb24ebf818dc64ee467bb410d621636 Mon Sep 17 00:00:00 2001 From: lcard Date: Wed, 23 Aug 2023 14:24:52 +0100 Subject: [PATCH 03/17] Fix --- .gitignore | 4 ++-- ui/src/__tests__/subject/create.test.tsx | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 2b0728b..47550f1 100644 --- a/.gitignore +++ b/.gitignore @@ -182,5 +182,5 @@ docs/_build/ .terraform/ .terraform.lock.hcl -playwright/.auth -playwright/.downloads \ No newline at end of file +ui/playwright/.auth +ui/playwright/.downloads \ No newline at end of file diff --git a/ui/src/__tests__/subject/create.test.tsx b/ui/src/__tests__/subject/create.test.tsx index 111beb9..0661ab3 100644 --- a/ui/src/__tests__/subject/create.test.tsx +++ b/ui/src/__tests__/subject/create.test.tsx @@ -48,6 +48,7 @@ describe('Page: Subject Create', () => { const mockData = { client_name: 'James Bond', + // pragma: allowlist secret client_secret: 'secret-code-word', client_id: 'id-abc123', permissions: ['DATA_ADMIN', 'READ_PRIVATE'] From a8b4e08100402f82930788e413d86ade7a5ebaf6 Mon Sep 17 00:00:00 2001 From: lcard Date: Wed, 23 Aug 2023 14:32:06 +0100 Subject: [PATCH 04/17] remove file From 53ee48aa6eb1b2e5cc02e490bab452a3bb0c9ff1 Mon Sep 17 00:00:00 2001 From: lcard Date: Wed, 23 Aug 2023 14:33:56 +0100 Subject: [PATCH 05/17] move pragma comment --- ui/src/__tests__/subject/create.test.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ui/src/__tests__/subject/create.test.tsx b/ui/src/__tests__/subject/create.test.tsx index 0661ab3..a6aca10 100644 --- a/ui/src/__tests__/subject/create.test.tsx +++ b/ui/src/__tests__/subject/create.test.tsx @@ -48,8 +48,7 @@ describe('Page: Subject Create', () => { const mockData = { client_name: 'James Bond', - // pragma: allowlist secret - client_secret: 'secret-code-word', + client_secret: 'secret-code-word', // pragma: allowlist secret client_id: 'id-abc123', permissions: ['DATA_ADMIN', 'READ_PRIVATE'] } From 1262666ec2f54fc73ad8fce15a394241fe363187 Mon Sep 17 00:00:00 2001 From: lcard Date: Wed, 23 Aug 2023 14:43:04 +0100 Subject: [PATCH 06/17] fixes --- Makefile | 4 +- api/api/controller/auth.py | 6 ++- api/api/controller/datasets.py | 7 +-- api/api/domain/metadata_search.py | 59 --------------------- api/test/api/controller/test_datasets.py | 66 ++++++++++++------------ 5 files changed, 39 insertions(+), 103 deletions(-) delete mode 100644 api/api/domain/metadata_search.py diff --git a/Makefile b/Makefile index 6e11259..29ed4ce 100644 --- a/Makefile +++ b/Makefile @@ -11,9 +11,7 @@ help: ## List targets and description precommit: pre-commit install -security-check: - @$(MAKE) detect-secrets - @$(MAKE) detect-vulnerabilities +security-check: detect-secrets detect-vulnerabilities detect-secrets: @git ls-files -z | xargs -0 detect-secrets-hook --baseline .secrets.baseline diff --git a/api/api/controller/auth.py b/api/api/controller/auth.py index fbcb64c..92a9815 100644 --- a/api/api/controller/auth.py +++ b/api/api/controller/auth.py @@ -65,7 +65,9 @@ async def redirect_oauth_token_request(request: Request): } payload = await _load_json_bytes_to_dict(request) - response = requests.post(IDENTITY_PROVIDER_TOKEN_URL, headers=headers, data=payload) + response = requests.post( + IDENTITY_PROVIDER_TOKEN_URL, headers=headers, data=payload, timeout=5 + ) return response.json() @@ -114,7 +116,7 @@ async def _get_access_token(auth, code, cognito_user_login_client_id): "code": code, } response = requests.post( - IDENTITY_PROVIDER_TOKEN_URL, auth=auth, headers=headers, data=payload + IDENTITY_PROVIDER_TOKEN_URL, auth=auth, headers=headers, data=payload, timeout=5 ) response_content = json.loads(response.content.decode(CONTENT_ENCODING)) access_token = response_content["access_token"] diff --git a/api/api/controller/datasets.py b/api/api/controller/datasets.py index 6c4e759..0e3c2e1 100644 --- a/api/api/controller/datasets.py +++ b/api/api/controller/datasets.py @@ -43,7 +43,6 @@ from api.domain.dataset_filters import DatasetFilters from api.domain.dataset_metadata import DatasetMetadata from api.domain.schema_metadata import SchemaMetadata -from api.domain.metadata_search import metadata_search_query from api.domain.mime_type import MimeType from api.domain.sql_query import SQLQuery from api.domain.Jobs.Job import generate_uuid @@ -128,11 +127,7 @@ class EnrichedMetadata(SchemaMetadata): include_in_schema=False, ) async def search_dataset_metadata(term: str): - sql_query = metadata_search_query(term) - df = athena_adapter.query_sql(sql_query) - df["version"] = df["version"].fillna(value="0") - df["data"] = df["data"].fillna(value="") - return df.to_dict("records") + return None @datasets_router.get( diff --git a/api/api/domain/metadata_search.py b/api/api/domain/metadata_search.py deleted file mode 100644 index c736725..0000000 --- a/api/api/domain/metadata_search.py +++ /dev/null @@ -1,59 +0,0 @@ -from jinja2 import Template -from typing import List - -from api.common.config.aws import GLUE_CATALOGUE_DB_NAME, METADATA_CATALOGUE_DB_NAME - - -DATASET_COLUMN = "dataset" -DOMAIN_COLUMN = "domain" -VERSION_COLUMN = "version" -DATA_COLUMN = "data" -DATA_TYPE_COLUMN = "data_type" - -# fmt: off -METADATA_QUERY = Template( # nosec - f""" -SELECT * FROM ( - SELECT - metadata.dataset as {DATASET_COLUMN}, - metadata.domain as {DOMAIN_COLUMN}, - metadata.version as {VERSION_COLUMN}, - "column".name as {DATA_COLUMN}, - 'column_name' as {DATA_TYPE_COLUMN} - FROM "{GLUE_CATALOGUE_DB_NAME}"."{METADATA_CATALOGUE_DB_NAME}" - CROSS JOIN UNNEST("columns") AS t ("column") - UNION ALL - SELECT - metadata.dataset as {DATASET_COLUMN}, - metadata.domain as {DOMAIN_COLUMN}, - metadata.version as {VERSION_COLUMN}, - metadata.description as {DATA_COLUMN}, - 'description' as {DATA_TYPE_COLUMN} - FROM "{GLUE_CATALOGUE_DB_NAME}"."{METADATA_CATALOGUE_DB_NAME}" - UNION ALL - SELECT - metadata.dataset as {DATASET_COLUMN}, - metadata.domain as {DOMAIN_COLUMN}, - metadata.version as {VERSION_COLUMN}, - metadata.dataset as {DATA_COLUMN}, - 'dataset_name' as {DATA_TYPE_COLUMN} - FROM "{GLUE_CATALOGUE_DB_NAME}"."{METADATA_CATALOGUE_DB_NAME}" -) -WHERE {{{{ where_clause }}}} -""" -) -# fmt: on - - -def generate_where_clause(search_term: str) -> List[str]: - return " OR ".join( - [ - f"lower({DATA_COLUMN}) LIKE '%{word.lower()}%'" - for word in search_term.split(" ") - ] - ) - - -def metadata_search_query(search_term: str) -> str: - where_clause = generate_where_clause(search_term) - return METADATA_QUERY.render(where_clause=where_clause) diff --git a/api/test/api/controller/test_datasets.py b/api/test/api/controller/test_datasets.py index 9e07410..e14a69c 100644 --- a/api/test/api/controller/test_datasets.py +++ b/api/test/api/controller/test_datasets.py @@ -634,39 +634,39 @@ def test_returns_enriched_metadata_for_datasets_with_certain_sensitivity( assert response.json() == expected_response -class TestSearchDatasets(BaseClientTest): - @patch.object(AthenaAdapter, "query_sql") - @patch("api.controller.datasets.metadata_search_query") - def test_search_dataset_metadata(self, mock_metadata_search_query, mock_query_sql): - mock_query = "SELECT * FROM table" - - mock_metadata_search_query.return_value = mock_query - - mock_data = [ - { - "dataset": "test", - "data": "foo", - "version": "1", - "data_type": "column", - }, - { - "dataset": "bar", - "data": "bar", - "version": "1", - "data_type": "table_name", - }, - ] - - mock_query_sql.return_value = pd.DataFrame(mock_data) - response = self.client.get( - f"{BASE_API_PATH}/datasets/search/foo bar", - headers={"Authorization": "Bearer test-token"}, - ) - - mock_metadata_search_query.assert_called_once_with("foo bar") - mock_query_sql.assert_called_once_with(mock_query) - assert response.status_code == 200 - assert response.json() == mock_data +# class TestSearchDatasets(BaseClientTest): +# @patch.object(AthenaAdapter, "query_sql") +# @patch("api.controller.datasets.metadata_search_query") +# def test_search_dataset_metadata(self, mock_metadata_search_query, mock_query_sql): +# mock_query = "SELECT * FROM table" + +# mock_metadata_search_query.return_value = mock_query + +# mock_data = [ +# { +# "dataset": "test", +# "data": "foo", +# "version": "1", +# "data_type": "column", +# }, +# { +# "dataset": "bar", +# "data": "bar", +# "version": "1", +# "data_type": "table_name", +# }, +# ] + +# mock_query_sql.return_value = pd.DataFrame(mock_data) +# response = self.client.get( +# f"{BASE_API_PATH}/datasets/search/foo bar", +# headers={"Authorization": "Bearer test-token"}, +# ) + +# mock_metadata_search_query.assert_called_once_with("foo bar") +# mock_query_sql.assert_called_once_with(mock_query) +# assert response.status_code == 200 +# assert response.json() == mock_data class TestDatasetInfo(BaseClientTest): From 03d62c676334c67146d132d74b7f7e840250bf78 Mon Sep 17 00:00:00 2001 From: lcard Date: Wed, 23 Aug 2023 15:10:58 +0100 Subject: [PATCH 07/17] fix sdk and api --- .env.example | 13 +++--- api/test/api/controller/test_datasets.py | 1 - api/test/api/domain/test_metadata_search.py | 51 --------------------- sdk/tests/test_items/test_schema.py | 24 ++++++++-- 4 files changed, 28 insertions(+), 61 deletions(-) delete mode 100644 api/test/api/domain/test_metadata_search.py diff --git a/.env.example b/.env.example index 86dc1c0..b0399a9 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,13 @@ -AWS_ACCOUNT= -AWS_REGION= +AWS_ACCOUNT=123456 +AWS_REGION=eu-west-2 # API Specific -COGNITO_USER_POOL_ID=rapid-pool -DATA_BUCKET=rapid-bucket -DOMAIN_NAME=rapid-domain -RESOURCE_PREFIX=rapid ALLOWED_EMAIL_DOMAINS=example1.com,example2.com +DATA_BUCKET=the-bucket +RESOURCE_PREFIX=rapid +DOMAIN_NAME=example.com +COGNITO_USER_POOL_ID=11111111 +LAYERS=raw,layer # SDK Specific RAPID_CLIENT_ID= diff --git a/api/test/api/controller/test_datasets.py b/api/test/api/controller/test_datasets.py index e14a69c..04cec01 100644 --- a/api/test/api/controller/test_datasets.py +++ b/api/test/api/controller/test_datasets.py @@ -4,7 +4,6 @@ import pandas as pd import pytest -from api.adapter.athena_adapter import AthenaAdapter from api.adapter.s3_adapter import S3Adapter from api.application.services.authorisation.dataset_access_evaluator import ( DatasetAccessEvaluator, diff --git a/api/test/api/domain/test_metadata_search.py b/api/test/api/domain/test_metadata_search.py deleted file mode 100644 index 0d8d513..0000000 --- a/api/test/api/domain/test_metadata_search.py +++ /dev/null @@ -1,51 +0,0 @@ -import pytest - -from api.domain.metadata_search import generate_where_clause, metadata_search_query - - -@pytest.mark.parametrize( - "term, expected", - [ - ("foo", "lower(data) LIKE '%foo%'"), - ("foo bar", "lower(data) LIKE '%foo%' OR lower(data) LIKE '%bar%'"), - ], -) -def test_generate_where_clause(term, expected): - res = generate_where_clause(term) - assert expected == res - - -def test_metadata_search_query(): - search_term = "foo bar" - expected = """ -SELECT * FROM ( - SELECT - metadata.dataset as dataset, - metadata.domain as domain, - metadata.version as version, - "column".name as data, - 'column_name' as data_type - FROM "rapid_catalogue_db"."rapid_metadata_table" - CROSS JOIN UNNEST("columns") AS t ("column") - UNION ALL - SELECT - metadata.dataset as dataset, - metadata.domain as domain, - metadata.version as version, - metadata.description as data, - 'description' as data_type - FROM "rapid_catalogue_db"."rapid_metadata_table" - UNION ALL - SELECT - metadata.dataset as dataset, - metadata.domain as domain, - metadata.version as version, - metadata.dataset as data, - 'dataset_name' as data_type - FROM "rapid_catalogue_db"."rapid_metadata_table" -) -WHERE lower(data) LIKE '%foo%' OR lower(data) LIKE '%bar%' - """ - res = metadata_search_query(search_term) - # Remove whitespace to compare - assert "".join(res.split()) == "".join(expected.split()) diff --git a/sdk/tests/test_items/test_schema.py b/sdk/tests/test_items/test_schema.py index 390f1c0..7dcee44 100644 --- a/sdk/tests/test_items/test_schema.py +++ b/sdk/tests/test_items/test_schema.py @@ -98,7 +98,18 @@ def test_create_columns_fails_name_none(self): Column(**_column) assert exc_info.value.errors() == [ - {"loc": ("name",), "msg": "field required", "type": "value_error.missing"} + { + "input": { + "partition_index": None, + "data_type": "object", + "allow_null": True, + "format": None, + }, + "loc": ("name",), + "msg": "Field required", + "type": "missing", + "url": "https://errors.pydantic.dev/2.2/v/missing", + } ] def test_create_columns_fails_data_type_none(self): @@ -114,9 +125,16 @@ def test_create_columns_fails_data_type_none(self): assert exc_info.value.errors() == [ { + "input": { + "name": "column_a", + "partition_index": None, + "allow_null": True, + "format": None, + }, "loc": ("data_type",), - "msg": "field required", - "type": "value_error.missing", + "msg": "Field required", + "type": "missing", + "url": "https://errors.pydantic.dev/2.2/v/missing", } ] From 9763eef1fb5cecf3574aaf3a423f42ec2520a075 Mon Sep 17 00:00:00 2001 From: lcard Date: Wed, 23 Aug 2023 15:22:24 +0100 Subject: [PATCH 08/17] fix UI and API --- api/test/api/controller/test_auth.py | 1 + ui/src/__tests__/app.test.tsx | 2 +- ui/src/__tests__/catalog.test.tsx | 2 +- ui/src/__tests__/data/delete.test.tsx | 2 +- ui/src/__tests__/data/download.test.tsx | 2 +- ui/src/__tests__/data/upload.test.tsx | 2 +- ui/src/__tests__/index.test.tsx | 2 +- ui/src/__tests__/login.test.tsx | 2 +- ui/src/__tests__/schema/create.test.tsx | 2 +- ui/src/__tests__/subject/create.test.tsx | 2 +- ui/src/__tests__/subject/modify.test.tsx | 2 +- ui/src/__tests__/tasks.test.tsx | 2 +- ui/src/components/Button/Button.test.tsx | 2 +- .../ConditionalWrapper.test.tsx | 2 +- .../SimpleTable/SimpleTable.test.tsx | 2 +- ui/src/pages/_app.tsx | 2 +- ui/src/pages/_document.tsx | 2 +- .../download/[layer]/[domain]/[dataset].tsx | 2 +- ui/src/pages/data/download/file.tsx | 2 +- ui/src/pages/tasks/[jobId].tsx | 2 +- ui/src/service/fetch.ts | 2 +- ui/src/utils/createEmotionCache.ts | 5 + ui/src/utils/data.test.ts | 41 +++++ ui/src/utils/data.ts | 25 ++++ ui/src/utils/index.ts | 13 ++ ui/src/utils/testing.tsx | 140 ++++++++++++++++++ ui/src/utils/url.test.ts | 65 ++++++++ ui/src/utils/url.ts | 21 +++ 28 files changed, 331 insertions(+), 20 deletions(-) create mode 100644 ui/src/utils/createEmotionCache.ts create mode 100644 ui/src/utils/data.test.ts create mode 100644 ui/src/utils/data.ts create mode 100644 ui/src/utils/index.ts create mode 100644 ui/src/utils/testing.tsx create mode 100644 ui/src/utils/url.test.ts create mode 100644 ui/src/utils/url.ts diff --git a/api/test/api/controller/test_auth.py b/api/test/api/controller/test_auth.py index c1d253b..c3a735c 100644 --- a/api/test/api/controller/test_auth.py +++ b/api/test/api/controller/test_auth.py @@ -42,5 +42,6 @@ def test_calls_cognito_for_access_token_when_callback_is_called_with_temporary_c "redirect_uri": COGNITO_REDIRECT_URI, "code": temporary_code, }, + timeout=5, ) mock_redirect.assert_called_once_with(url="/", status_code=HTTP_302_FOUND) diff --git a/ui/src/__tests__/app.test.tsx b/ui/src/__tests__/app.test.tsx index 1a5614b..200aa37 100644 --- a/ui/src/__tests__/app.test.tsx +++ b/ui/src/__tests__/app.test.tsx @@ -1,5 +1,5 @@ import fetchMock from 'jest-fetch-mock' -import { renderWithProviders } from '@/lib/test-utils' +import { renderWithProviders } from '@/utils/testing' import AppPage from '@/pages/_app' jest.useFakeTimers() diff --git a/ui/src/__tests__/catalog.test.tsx b/ui/src/__tests__/catalog.test.tsx index 40b7791..4a5fdf9 100644 --- a/ui/src/__tests__/catalog.test.tsx +++ b/ui/src/__tests__/catalog.test.tsx @@ -1,6 +1,6 @@ import { screen, waitForElementToBeRemoved } from '@testing-library/react' import fetchMock from 'jest-fetch-mock' -import { renderWithProviders } from '@/lib/test-utils' +import { renderWithProviders } from '@/utils/testing' import CatalogPage from '@/pages/catalog/[search]' import { MetadataSearchResponse } from '@/service/types' diff --git a/ui/src/__tests__/data/delete.test.tsx b/ui/src/__tests__/data/delete.test.tsx index 30e1a51..ba9babb 100644 --- a/ui/src/__tests__/data/delete.test.tsx +++ b/ui/src/__tests__/data/delete.test.tsx @@ -6,7 +6,7 @@ import { import userEvent from '@testing-library/user-event' import fetchMock from 'jest-fetch-mock' import DeletePage from '@/pages/data/delete' -import { mockDataset, mockDataSetsList, renderWithProviders, selectAutocompleteOption } from '@/lib/test-utils' +import { mockDataset, mockDataSetsList, renderWithProviders, selectAutocompleteOption } from '@/utils/testing' import { DeleteDatasetResponse } from '@/service/types' describe('Page: Delete page', () => { diff --git a/ui/src/__tests__/data/download.test.tsx b/ui/src/__tests__/data/download.test.tsx index b16f4d6..1163b22 100644 --- a/ui/src/__tests__/data/download.test.tsx +++ b/ui/src/__tests__/data/download.test.tsx @@ -5,7 +5,7 @@ import { } from '@testing-library/react' import userEvent from '@testing-library/user-event' import fetchMock from 'jest-fetch-mock' -import { mockDataset, mockDataSetsList, renderWithProviders } from '@/lib/test-utils' +import { mockDataset, mockDataSetsList, renderWithProviders } from '@/utils/testing' import DownloadPage from '@/pages/data/download/' const pushSpy = jest.fn() diff --git a/ui/src/__tests__/data/upload.test.tsx b/ui/src/__tests__/data/upload.test.tsx index 0ae028e..fc83e0b 100644 --- a/ui/src/__tests__/data/upload.test.tsx +++ b/ui/src/__tests__/data/upload.test.tsx @@ -6,7 +6,7 @@ import { } from '@testing-library/react' import userEvent from '@testing-library/user-event' import fetchMock from 'jest-fetch-mock' -import { mockDataset, mockDataSetsList, renderWithProviders, selectAutocompleteOption } from '@/lib/test-utils' +import { mockDataset, mockDataSetsList, renderWithProviders, selectAutocompleteOption } from '@/utils/testing' import UploadPage from '@/pages/data/upload' import { UploadDatasetResponse } from '@/service/types' diff --git a/ui/src/__tests__/index.test.tsx b/ui/src/__tests__/index.test.tsx index b5347de..cef5b8b 100644 --- a/ui/src/__tests__/index.test.tsx +++ b/ui/src/__tests__/index.test.tsx @@ -1,6 +1,6 @@ import { screen, waitFor } from '@testing-library/react' import fetchMock from 'jest-fetch-mock' -import { renderWithProviders } from '@/lib/test-utils' +import { renderWithProviders } from '@/utils/testing' import IndexPage from '@/pages/index' import { MethodsResponse } from '@/service/types' diff --git a/ui/src/__tests__/login.test.tsx b/ui/src/__tests__/login.test.tsx index 097c1f2..741784f 100644 --- a/ui/src/__tests__/login.test.tsx +++ b/ui/src/__tests__/login.test.tsx @@ -1,6 +1,6 @@ import { screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react' import fetchMock from 'jest-fetch-mock' -import { renderWithProviders } from '@/lib/test-utils' +import { renderWithProviders } from '@/utils/testing' import LoginPage from '@/pages/login' import { AuthResponse, GetLoginResponse } from '@/service/types' diff --git a/ui/src/__tests__/schema/create.test.tsx b/ui/src/__tests__/schema/create.test.tsx index 73ae851..c6975c7 100644 --- a/ui/src/__tests__/schema/create.test.tsx +++ b/ui/src/__tests__/schema/create.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react' import userEvent from '@testing-library/user-event' import fetchMock from 'jest-fetch-mock' -import { renderWithProviders } from '@/lib/test-utils' +import { renderWithProviders } from '@/utils/testing' import SchemaCreatePage from '@/pages/schema/create' const mockProps = jest.fn() diff --git a/ui/src/__tests__/subject/create.test.tsx b/ui/src/__tests__/subject/create.test.tsx index a6aca10..7957b42 100644 --- a/ui/src/__tests__/subject/create.test.tsx +++ b/ui/src/__tests__/subject/create.test.tsx @@ -1,7 +1,7 @@ import { screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react' import userEvent from '@testing-library/user-event' import fetchMock from 'jest-fetch-mock' -import { renderWithProviders, mockPermissionUiResponse } from '@/lib/test-utils' +import { renderWithProviders, mockPermissionUiResponse } from '@/utils/testing' import SubjectCreatePage from '@/pages/subject/create/index' diff --git a/ui/src/__tests__/subject/modify.test.tsx b/ui/src/__tests__/subject/modify.test.tsx index 7f12c2f..0cbb6a2 100644 --- a/ui/src/__tests__/subject/modify.test.tsx +++ b/ui/src/__tests__/subject/modify.test.tsx @@ -6,7 +6,7 @@ import { } from '@testing-library/react' import userEvent from '@testing-library/user-event' import fetchMock from 'jest-fetch-mock' -import { renderWithProviders } from '@/lib/test-utils' +import { renderWithProviders } from '@/utils/testing' import SubjectModifyPage from '@/pages/subject/modify/index' const mockData: Array> = [ diff --git a/ui/src/__tests__/tasks.test.tsx b/ui/src/__tests__/tasks.test.tsx index 23a880c..5c5ecad 100644 --- a/ui/src/__tests__/tasks.test.tsx +++ b/ui/src/__tests__/tasks.test.tsx @@ -1,6 +1,6 @@ import { screen, waitForElementToBeRemoved } from '@testing-library/react' import fetchMock from 'jest-fetch-mock' -import { renderWithProviders } from '@/lib/test-utils' +import { renderWithProviders } from '@/utils/testing' import TasksPage from '@/pages/tasks/index' import { AllJobsResponse } from '@/service/types' diff --git a/ui/src/components/Button/Button.test.tsx b/ui/src/components/Button/Button.test.tsx index 8297303..77bc937 100644 --- a/ui/src/components/Button/Button.test.tsx +++ b/ui/src/components/Button/Button.test.tsx @@ -1,5 +1,5 @@ import { screen } from '@testing-library/react' -import { renderWithProviders } from '@/lib/test-utils' +import { renderWithProviders } from '@/utils/testing' import Button from './Button' describe('Button', () => { diff --git a/ui/src/components/ConditionalWrapper/ConditionalWrapper.test.tsx b/ui/src/components/ConditionalWrapper/ConditionalWrapper.test.tsx index d8876b0..cf6fac5 100644 --- a/ui/src/components/ConditionalWrapper/ConditionalWrapper.test.tsx +++ b/ui/src/components/ConditionalWrapper/ConditionalWrapper.test.tsx @@ -1,6 +1,6 @@ import { screen } from '@testing-library/react' import ConditionalWrapper from './ConditionalWrapper' -import { renderWithProviders } from '@/lib/test-utils' +import { renderWithProviders } from '@/utils/testing' import { FC } from 'react' const Content: FC = () =>
test
diff --git a/ui/src/components/SimpleTable/SimpleTable.test.tsx b/ui/src/components/SimpleTable/SimpleTable.test.tsx index 53324ee..be3fdf0 100644 --- a/ui/src/components/SimpleTable/SimpleTable.test.tsx +++ b/ui/src/components/SimpleTable/SimpleTable.test.tsx @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import SimpleTable from './SimpleTable' -import { renderWithProviders, screen } from '@/lib/test-utils' +import { renderWithProviders, screen } from '@/utils/testing' describe('SimpleTable', () => { it('empty row', async () => { diff --git a/ui/src/pages/_app.tsx b/ui/src/pages/_app.tsx index 0f7e0af..b82a79a 100644 --- a/ui/src/pages/_app.tsx +++ b/ui/src/pages/_app.tsx @@ -1,6 +1,6 @@ import { ThemeProvider } from '@/components' import { CacheProvider, EmotionCache } from '@emotion/react' -import createEmotionCache from '@/lib/createEmotionCache' +import createEmotionCache from '@/utils/createEmotionCache' import { ErrorBoundary } from 'react-error-boundary' import { ReactNode, useEffect } from 'react' import { AppProps } from 'next/app' diff --git a/ui/src/pages/_document.tsx b/ui/src/pages/_document.tsx index 11e6451..0b7dd95 100644 --- a/ui/src/pages/_document.tsx +++ b/ui/src/pages/_document.tsx @@ -7,7 +7,7 @@ import Document, { } from 'next/document' import createEmotionServer from '@emotion/server/create-instance' import theme from '@/style/theme' -import createEmotionCache from '@/lib/createEmotionCache' +import createEmotionCache from '@/utils/createEmotionCache' import { ReactNode } from 'react' type DocumentProps = { diff --git a/ui/src/pages/data/download/[layer]/[domain]/[dataset].tsx b/ui/src/pages/data/download/[layer]/[domain]/[dataset].tsx index 040b7ef..e74cf96 100644 --- a/ui/src/pages/data/download/[layer]/[domain]/[dataset].tsx +++ b/ui/src/pages/data/download/[layer]/[domain]/[dataset].tsx @@ -10,7 +10,7 @@ import { Alert } from '@/components' import ErrorCard from '@/components/ErrorCard/ErrorCard' -import { asVerticalTableList } from '@/lib' +import { asVerticalTableList } from '@/utils' import { getDatasetInfo, queryDataset } from '@/service' import { DataFormats } from '@/service/types' import { Typography, LinearProgress } from '@mui/material' diff --git a/ui/src/pages/data/download/file.tsx b/ui/src/pages/data/download/file.tsx index fc29325..bd0fce3 100644 --- a/ui/src/pages/data/download/file.tsx +++ b/ui/src/pages/data/download/file.tsx @@ -10,7 +10,7 @@ import { } from '@/components' import { Typography } from '@mui/material' import { useRouter } from 'next/router' -import { asVerticalTableList } from '@/lib' +import { asVerticalTableList } from '@/utils' function FilePage() { const router = useRouter() diff --git a/ui/src/pages/tasks/[jobId].tsx b/ui/src/pages/tasks/[jobId].tsx index 759c2f1..5efeb22 100644 --- a/ui/src/pages/tasks/[jobId].tsx +++ b/ui/src/pages/tasks/[jobId].tsx @@ -1,7 +1,7 @@ import ErrorCard from '@/components/ErrorCard/ErrorCard' import AccountLayout from '@/components/Layout/AccountLayout' import SimpleTable from '@/components/SimpleTable/SimpleTable' -import { asVerticalTableList } from '@/lib' +import { asVerticalTableList } from '@/utils' import { getJob } from '@/service' import { Card, Typography, LinearProgress } from '@mui/material' import { useQuery } from '@tanstack/react-query' diff --git a/ui/src/service/fetch.ts b/ui/src/service/fetch.ts index f7f84ef..3828c40 100644 --- a/ui/src/service/fetch.ts +++ b/ui/src/service/fetch.ts @@ -17,7 +17,7 @@ import { PermissionUiResponse, SubjectPermission } from './types' -import { api } from '@/lib/data-utils' +import { api } from '@/utils/data' export const getAuthStatus = async (): Promise => { const res = await api(`/api/auth`, { method: 'GET' }) diff --git a/ui/src/utils/createEmotionCache.ts b/ui/src/utils/createEmotionCache.ts new file mode 100644 index 0000000..0de053b --- /dev/null +++ b/ui/src/utils/createEmotionCache.ts @@ -0,0 +1,5 @@ +import createCache from '@emotion/cache' + +const createEmotionCache = () => createCache({ key: 'css', prepend: true }) + +export default createEmotionCache diff --git a/ui/src/utils/data.test.ts b/ui/src/utils/data.test.ts new file mode 100644 index 0000000..240f91a --- /dev/null +++ b/ui/src/utils/data.test.ts @@ -0,0 +1,41 @@ +import { api } from './data' +import fetchMock from 'jest-fetch-mock' +import { defaultError } from '@/lang' + +const mockSuccess = { fruit: 'apples' } + +describe('api()', () => { + afterEach(() => { + fetchMock.resetMocks() + }) + + it('success', async () => { + fetchMock.mockResponseOnce(JSON.stringify(mockSuccess), { status: 200 }) + const data = await (await api('/api')).json() + expect(data).toEqual(expect.objectContaining(mockSuccess)) + }) + + it('default error', async () => { + fetchMock.mockResponseOnce(JSON.stringify(mockSuccess), { status: 401 }) + + try { + await api('/api') + } catch (e) { + expect(e.message).toEqual(defaultError) + } + }) + + it('custom error', async () => { + const errorMessage = 'my custom error' + + fetchMock.mockResponseOnce(JSON.stringify({ details: 'my custom error' }), { + status: 401 + }) + + try { + await api('/api') + } catch (e) { + expect(e.message).toEqual(errorMessage) + } + }) +}) diff --git a/ui/src/utils/data.ts b/ui/src/utils/data.ts new file mode 100644 index 0000000..d7e44e4 --- /dev/null +++ b/ui/src/utils/data.ts @@ -0,0 +1,25 @@ +import { createUrl } from './url' +import { defaultError } from '@/lang' + +export type ParamsType = Record + +export const api = async ( + path: RequestInfo | URL, + init: RequestInit = {}, + params?: ParamsType +): Promise => { + const API_URL = process.env.NEXT_PUBLIC_API_URL + const baseUrl = API_URL ? `${API_URL}${path}` : path + const url = createUrl(`${baseUrl}`, params) + let detailMessage + const res: Response = await fetch(url, { + credentials: 'include', + ...init + }) + if (res.ok) return res + try { + const { details } = await res.json() + detailMessage = details + } catch (e) { } + throw new Error(detailMessage || defaultError) +} diff --git a/ui/src/utils/index.ts b/ui/src/utils/index.ts new file mode 100644 index 0000000..c5fb87f --- /dev/null +++ b/ui/src/utils/index.ts @@ -0,0 +1,13 @@ +import { TableCellProps } from '@mui/material' + +export const asVerticalTableList = ( + list: { + name: string + value: string + }[] +) => [ + ...list.map(({ name, value }) => [ + { children: name, component: 'th' }, + { children: value } + ]) +] diff --git a/ui/src/utils/testing.tsx b/ui/src/utils/testing.tsx new file mode 100644 index 0000000..3356466 --- /dev/null +++ b/ui/src/utils/testing.tsx @@ -0,0 +1,140 @@ +import { fireEvent, render, renderHook, RenderOptions, screen, waitFor } from '@testing-library/react' +import { ThemeProvider } from '@/components' +import { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { Dataset, PermissionUiResponse } from '@/service/types' + +beforeAll(() => { + Object.defineProperty(global, 'sessionStorage', { value: mockStorage }) + Object.defineProperty(global, 'localStorage', { value: mockStorage }) + jest.spyOn(console, 'error').mockImplementation(jest.fn()) +}) + +afterEach(() => { + window.sessionStorage.clear() +}) + +const mockStorage = (() => { + let store = {} + return { + getItem: function (key) { + return store[key] || null + }, + setItem: function (key, value) { + store[key] = value.toString() + }, + removeItem: function (key) { + delete store[key] + }, + clear: function () { + store = {} + } + } +})() + +export const wrapper = (ui) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false + } + } + }) + return ( + + + <>{ui} + + + ) +} + +export const renderWithProviders = async ( + ui: ReactNode, + options: Omit = {} +) => { + const view = await render(wrapper(ui), options) + return { + ...view, + rerender: (ui, options: Omit = {}) => + renderWithProviders(ui, { container: view.container, ...options }) + } +} + +export const renderHookWithProviders: typeof renderHook = (...parameters) => + renderHook(parameters[0], { + wrapper: ({ children }) => wrapper(children), + ...parameters[1] + }) + +export const bugfixForTimeout = async () => + await waitFor(() => new Promise((resolve) => setTimeout(resolve, 0))) + +export * from '@testing-library/react' +export { renderWithProviders as render } + +export const mockDataset: Dataset = { + layer: 'layer', + domain: 'domain', + dataset: 'dataset', + version: 1 +} + +export const mockDataSetsList: Dataset[] = [ + { + layer: 'layer', + domain: 'Pizza', + dataset: 'bit_complicated', + version: 3 + }, + { + layer: 'layer', + domain: 'Pizza', + dataset: 'again_complicated_high', + version: 3 + }, + { + layer: 'layer', + domain: 'Apple', + dataset: 'juicy', + version: 2 + } +] + +export const mockPermissionUiResponse: PermissionUiResponse = { + "DATA_ADMIN": "DATA_ADMIN", + "USER_ADMIN": "USER_ADMIN", + "READ": { + "ALL": { + "ALL": "READ_ALL", + "PROTECTED": { + "TEST": "READ_ALL_PROTECTED_TEST", + }, + }, + }, + "WRITE": { + "ALL": { + "ALL": "WRITE_ALL", + "PROTECTED": { + "TEST": "WRITE_ALL_PROTECTED_TEST", + }, + }, + "DEFAULT": { + "ALL": "WRITE_DEFAULT_ALL", + "PROTECTED": { + "TEST": "WRITE_DEFAULT_PROTECTED_TEST", + }, + }, + } +} + + +export const selectAutocompleteOption = (id, value) => { + const autocomplete = screen.getByTestId(id); + const input = autocomplete.querySelector('input') + autocomplete.focus() + fireEvent.change(input, { target: { value: value } }) + fireEvent.keyDown(autocomplete, { key: 'ArrowDown' }) + fireEvent.keyDown(autocomplete, { key: 'Enter' }) + expect(input).toHaveValue(value) +} diff --git a/ui/src/utils/url.test.ts b/ui/src/utils/url.test.ts new file mode 100644 index 0000000..39894b1 --- /dev/null +++ b/ui/src/utils/url.test.ts @@ -0,0 +1,65 @@ +import { createUrl, isUrlInternal } from './url' + +describe('createUrl()', () => { + it('returns url with querystring', () => { + expect(createUrl('/path', { food: 'pizza', fruit: 'apple' })).toEqual( + '/path?food=pizza&fruit=apple' + ) + + expect(createUrl('/path', { food: ['pizza', 'chips'], fruit: 'apple' })).toEqual( + '/path?food=pizza%2Cchips&fruit=apple' + ) + }) + + it('empty params', () => { + expect(createUrl('/path', {})).toEqual('/path') + expect(createUrl('/path')).toEqual('/path') + }) +}) + +describe('isUrlInternal()', () => { + const sitename = 'http://myapp/' + const { location } = window + + beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (window as any).location + }) + + afterAll(() => { + window.location = location + }) + + beforeEach(() => { + window.location = { + ...location, + href: sitename + } + }) + + it('url is only path', () => { + expect(isUrlInternal('/someurl')).toBeTruthy() + expect(isUrlInternal('/someurl?param=1')).toBeTruthy() + }) + + it('url contains full site', () => { + expect(isUrlInternal(sitename)).toBeTruthy() + expect(isUrlInternal(sitename + 'product/')).toBeTruthy() + expect(isUrlInternal(sitename + '?param=1')).toBeTruthy() + }) + + it('throws error if invalid url', () => { + expect(() => isUrlInternal('')).toThrowError('Invalid URL:') + expect(() => isUrlInternal('*^&*YH')).toThrowError('Invalid URL:') + }) + + it('throws error if invalid currentUrl', () => { + expect(() => isUrlInternal(sitename, '')).toThrowError('Invalid URL:') + expect(() => isUrlInternal(sitename, '*^&*YH')).toThrowError('Invalid URL:') + }) + + it('url is external site', () => { + expect(isUrlInternal('http://externalapp/')).toBeFalsy() + expect(isUrlInternal('https://myapp/')).toBeFalsy() + }) +}) diff --git a/ui/src/utils/url.ts b/ui/src/utils/url.ts new file mode 100644 index 0000000..975c278 --- /dev/null +++ b/ui/src/utils/url.ts @@ -0,0 +1,21 @@ +export const createUrl = ( + url: RequestInfo | URL, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params?: string | URLSearchParams | Record | string[][] +): string => { + const queryString = new URLSearchParams(params).toString() + return `${url}${queryString && `?${queryString}`}` +} + +export const isUrlInternal = ( + url: string, + currenSite = window.location.href +): boolean => { + if (url.charAt(0) === '/') return true + + const fullUrl = new URL(url).origin + const fullSite = new URL(currenSite).origin + + if (fullUrl === fullSite) return true + return false +} From 6fa36b3916eb7ed1f53b6fff1b492e0972bb0b91 Mon Sep 17 00:00:00 2001 From: lcard Date: Wed, 23 Aug 2023 15:44:04 +0100 Subject: [PATCH 09/17] fix tagging --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 29ed4ce..555cdee 100644 --- a/Makefile +++ b/Makefile @@ -64,7 +64,7 @@ api-create-local-venv: ## Create the api local venv for deployment @cd api/; ./local-venv-setup.sh api-create-image: ## Manually (re)create the api environment image - @cd api/; ./batect runtime-environment + @cd api/; ./batect --tag-image service-image=rapid-api-service-image runtime-environment api-shell: ## Run the api application and drop me into a shell @cd api/; ./batect shell From 999418ab57b87667c2929a7be486a5477fed7e2b Mon Sep 17 00:00:00 2001 From: lcard Date: Wed, 23 Aug 2023 17:30:04 +0100 Subject: [PATCH 10/17] fix --- ui/src/pages/_app.tsx | 2 +- ui/src/pages/_document.tsx | 2 +- ui/src/utils/createEmotionCache.ts | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 ui/src/utils/createEmotionCache.ts diff --git a/ui/src/pages/_app.tsx b/ui/src/pages/_app.tsx index 0f7e0af..b82a79a 100644 --- a/ui/src/pages/_app.tsx +++ b/ui/src/pages/_app.tsx @@ -1,6 +1,6 @@ import { ThemeProvider } from '@/components' import { CacheProvider, EmotionCache } from '@emotion/react' -import createEmotionCache from '@/lib/createEmotionCache' +import createEmotionCache from '@/utils/createEmotionCache' import { ErrorBoundary } from 'react-error-boundary' import { ReactNode, useEffect } from 'react' import { AppProps } from 'next/app' diff --git a/ui/src/pages/_document.tsx b/ui/src/pages/_document.tsx index 11e6451..0b7dd95 100644 --- a/ui/src/pages/_document.tsx +++ b/ui/src/pages/_document.tsx @@ -7,7 +7,7 @@ import Document, { } from 'next/document' import createEmotionServer from '@emotion/server/create-instance' import theme from '@/style/theme' -import createEmotionCache from '@/lib/createEmotionCache' +import createEmotionCache from '@/utils/createEmotionCache' import { ReactNode } from 'react' type DocumentProps = { diff --git a/ui/src/utils/createEmotionCache.ts b/ui/src/utils/createEmotionCache.ts new file mode 100644 index 0000000..0de053b --- /dev/null +++ b/ui/src/utils/createEmotionCache.ts @@ -0,0 +1,5 @@ +import createCache from '@emotion/cache' + +const createEmotionCache = () => createCache({ key: 'css', prepend: true }) + +export default createEmotionCache From 9d227cb33909cbc2911bd2147eec6c603e69d3da Mon Sep 17 00:00:00 2001 From: lcard Date: Wed, 23 Aug 2023 17:42:47 +0100 Subject: [PATCH 11/17] fix imports --- .../data/download/[layer]/[domain]/[dataset].tsx | 2 +- ui/src/pages/data/download/file.tsx | 2 +- ui/src/pages/tasks/[jobId].tsx | 2 +- ui/src/utils/index.ts | 13 +++++++++++++ 4 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 ui/src/utils/index.ts diff --git a/ui/src/pages/data/download/[layer]/[domain]/[dataset].tsx b/ui/src/pages/data/download/[layer]/[domain]/[dataset].tsx index 040b7ef..e74cf96 100644 --- a/ui/src/pages/data/download/[layer]/[domain]/[dataset].tsx +++ b/ui/src/pages/data/download/[layer]/[domain]/[dataset].tsx @@ -10,7 +10,7 @@ import { Alert } from '@/components' import ErrorCard from '@/components/ErrorCard/ErrorCard' -import { asVerticalTableList } from '@/lib' +import { asVerticalTableList } from '@/utils' import { getDatasetInfo, queryDataset } from '@/service' import { DataFormats } from '@/service/types' import { Typography, LinearProgress } from '@mui/material' diff --git a/ui/src/pages/data/download/file.tsx b/ui/src/pages/data/download/file.tsx index fc29325..bd0fce3 100644 --- a/ui/src/pages/data/download/file.tsx +++ b/ui/src/pages/data/download/file.tsx @@ -10,7 +10,7 @@ import { } from '@/components' import { Typography } from '@mui/material' import { useRouter } from 'next/router' -import { asVerticalTableList } from '@/lib' +import { asVerticalTableList } from '@/utils' function FilePage() { const router = useRouter() diff --git a/ui/src/pages/tasks/[jobId].tsx b/ui/src/pages/tasks/[jobId].tsx index 759c2f1..5efeb22 100644 --- a/ui/src/pages/tasks/[jobId].tsx +++ b/ui/src/pages/tasks/[jobId].tsx @@ -1,7 +1,7 @@ import ErrorCard from '@/components/ErrorCard/ErrorCard' import AccountLayout from '@/components/Layout/AccountLayout' import SimpleTable from '@/components/SimpleTable/SimpleTable' -import { asVerticalTableList } from '@/lib' +import { asVerticalTableList } from '@/utils' import { getJob } from '@/service' import { Card, Typography, LinearProgress } from '@mui/material' import { useQuery } from '@tanstack/react-query' diff --git a/ui/src/utils/index.ts b/ui/src/utils/index.ts new file mode 100644 index 0000000..c5fb87f --- /dev/null +++ b/ui/src/utils/index.ts @@ -0,0 +1,13 @@ +import { TableCellProps } from '@mui/material' + +export const asVerticalTableList = ( + list: { + name: string + value: string + }[] +) => [ + ...list.map(({ name, value }) => [ + { children: name, component: 'th' }, + { children: value } + ]) +] From 3261c65188140d06f5a967585a914fd388a3f9f4 Mon Sep 17 00:00:00 2001 From: lcard Date: Wed, 23 Aug 2023 17:51:04 +0100 Subject: [PATCH 12/17] fix ui-test --- .github/workflows/ui-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index e40849b..9dd149d 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -31,6 +31,6 @@ jobs: - name: run playwright tests run: npx playwright test ui/playwright env: - DOMAIN: "https://${{ secrets.DOMAIN }}" + DOMAIN: "https://${{ secrets.DOMAIN_NAME }}" RESOURCE_PREFIX: ${{ secrets.RESOURCE_PREFIX }} AWS_REGION: ${{ secrets.AWS_REGION }} From 65abc86f0a2f5aa7867316d93cf4539a4582a900 Mon Sep 17 00:00:00 2001 From: lcard Date: Wed, 23 Aug 2023 18:09:19 +0100 Subject: [PATCH 13/17] fix UI tests --- .github/workflows/ui-tests.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 9dd149d..444a4ae 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -26,10 +26,14 @@ jobs: npm install - name: Install playwright browsers - run: npm install @playwright/test -D + run: | + cd ui + npm install @playwright/test -D - name: run playwright tests - run: npx playwright test ui/playwright + run: | + cd ui + npx playwright test ui/playwright env: DOMAIN: "https://${{ secrets.DOMAIN_NAME }}" RESOURCE_PREFIX: ${{ secrets.RESOURCE_PREFIX }} From b393ecd729d4241f13948ea8d2a35fbda24c6290 Mon Sep 17 00:00:00 2001 From: Toby Drane Date: Tue, 29 Aug 2023 13:05:34 +0100 Subject: [PATCH 14/17] remove dynamo stream --- infrastructure/modules/data-workflow/dynamodb.tf | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/infrastructure/modules/data-workflow/dynamodb.tf b/infrastructure/modules/data-workflow/dynamodb.tf index ea2884e..e16ee4c 100644 --- a/infrastructure/modules/data-workflow/dynamodb.tf +++ b/infrastructure/modules/data-workflow/dynamodb.tf @@ -1,10 +1,9 @@ resource "aws_dynamodb_table" "schema_table" { # checkov:skip=CKV_AWS_119:No need for customer managed keys - name = "${var.resource-name-prefix}_schema_table" - hash_key = "PK" - range_key = "SK" - billing_mode = "PAY_PER_REQUEST" - stream_enabled = true + name = "${var.resource-name-prefix}_schema_table" + hash_key = "PK" + range_key = "SK" + billing_mode = "PAY_PER_REQUEST" attribute { name = "PK" type = "S" From 5a36759e2f77809a36490f081d302564ac5256df Mon Sep 17 00:00:00 2001 From: Toby Drane Date: Tue, 29 Aug 2023 13:22:31 +0100 Subject: [PATCH 15/17] revert kms key rotation --- infrastructure/modules/app-cluster/cloudtrail.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/modules/app-cluster/cloudtrail.tf b/infrastructure/modules/app-cluster/cloudtrail.tf index fef204a..05e42da 100644 --- a/infrastructure/modules/app-cluster/cloudtrail.tf +++ b/infrastructure/modules/app-cluster/cloudtrail.tf @@ -86,7 +86,7 @@ resource "aws_kms_key" "access_logs_key" { description = "This key is used to encrypt the access log objects" policy = data.aws_iam_policy_document.access_logs_key_policy.json tags = var.tags - enable_key_rotation = false + enable_key_rotation = true } resource "aws_cloudwatch_log_group" "access_logs_log_group" { From 2edf9656b3ee8bab7a4e5c2d94e5872981f99481 Mon Sep 17 00:00:00 2001 From: Toby Drane Date: Wed, 30 Aug 2023 19:00:13 +0100 Subject: [PATCH 16/17] fix v7 ui failing on unique modify users error --- .github/workflows/ui-tests.yml | 7 +- .gitignore | 5 +- .pre-commit-config.yaml | 69 +-- Makefile | 6 + ui/playwright/auth.setup.ts | 52 +-- ui/playwright/test-data-flow.spec.ts | 148 +++--- ui/playwright/test-homepage.spec.ts | 31 +- ui/playwright/test-user-flow.spec.ts | 64 ++- ui/playwright/utils.ts | 58 +++ .../PermissionsTable/PermissionsTable.tsx | 436 ++++++++++-------- ui/src/pages/subject/modify/[subjectId].tsx | 37 +- 11 files changed, 527 insertions(+), 386 deletions(-) create mode 100644 ui/playwright/utils.ts diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index dd280c9..2d83f3c 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -3,7 +3,7 @@ name: rAPId Integration Tests on: push: branches: - - "**" + - '**' workflow_dispatch: @@ -12,7 +12,6 @@ on: - opened jobs: - run-ui-test: runs-on: self-hosted @@ -32,8 +31,8 @@ jobs: run: npx playwright install-deps && npx playwright install - name: run playwright tests - run: npx playwright test ui/playwright + run: make ui-test-e2e env: - DOMAIN: ${{ secrets.DOMAIN }} + DOMAIN_NAME: ${{ secrets.DOMAIN_NAME }} RESOURCE_PREFIX: ${{ secrets.RESOURCE_PREFIX }} AWS_REGION: ${{ secrets.AWS_REGION }} diff --git a/.gitignore b/.gitignore index 2b0728b..b8e63d4 100644 --- a/.gitignore +++ b/.gitignore @@ -182,5 +182,6 @@ docs/_build/ .terraform/ .terraform.lock.hcl -playwright/.auth -playwright/.downloads \ No newline at end of file +ui/playwright/.auth +ui/playwright/.downloads +ui/test-results/ \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7276dfa..299c5ee 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,70 +1,75 @@ exclude: '^ui' repos: -- repo: https://github.com/pre-commit/pre-commit-hooks + - repo: https://github.com/pre-commit/pre-commit-hooks rev: v2.3.0 hooks: - - id: check-yaml - - id: check-json - - id: check-merge-conflict - - id: end-of-file-fixer - - id: trailing-whitespace -- repo: https://github.com/PyCQA/bandit + - id: check-yaml + - id: check-json + - id: check-merge-conflict + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/PyCQA/bandit rev: 1.7.5 hooks: - - id: bandit + - id: bandit exclude: '(test|docs)/*' -- repo: https://github.com/psf/black + - repo: https://github.com/psf/black rev: 22.6.0 hooks: - - id: black -- repo: https://github.com/Yelp/detect-secrets + - id: black + - repo: https://github.com/Yelp/detect-secrets rev: v1.3.0 hooks: - - id: detect-secrets + - id: detect-secrets exclude: docs/ -- repo: https://github.com/asottile/blacken-docs + - repo: https://github.com/asottile/blacken-docs rev: v1.12.1 hooks: - - id: blacken-docs -- repo: https://github.com/PyCQA/flake8 + - id: blacken-docs + - repo: https://github.com/PyCQA/flake8 rev: 4.0.1 hooks: - - id: flake8 + - id: flake8 args: ['--config', 'api/.flake8'] exclude: (docs/|get_latest_release_changelog.py) -# - repo: https://github.com/PyCQA/pylint -# rev: v2.15.5 -# hooks: -# - id: pylint -# exclude: (docs/|get_latest_release_changelog.py) -- repo: https://github.com/antonbabenko/pre-commit-terraform + # - repo: https://github.com/PyCQA/pylint + # rev: v2.15.5 + # hooks: + # - id: pylint + # exclude: (docs/|get_latest_release_changelog.py) + - repo: https://github.com/antonbabenko/pre-commit-terraform rev: v1.81.0 hooks: - - id: terraform_fmt + - id: terraform_fmt exclude: '^(?!infrastructure/).*' - - id: terraform_validate + - id: terraform_validate exclude: '^(?!infrastructure/).*' - - id: terraform_docs + - id: terraform_docs args: - - markdown table --recursive --output-file README.md . + - markdown table --recursive --output-file README.md . exclude: '^(?!infrastructure/).*' -- repo: https://github.com/bridgecrewio/checkov.git + - repo: https://github.com/bridgecrewio/checkov.git rev: 2.3.261 hooks: - - id: checkov + - id: checkov args: [--quiet, --compact] exclude: '^(?!infrastructure/).*' -- repo: local + - repo: local hooks: - - id: sdk_test + - id: sdk_test name: sdk_test language: system entry: bash -c 'make sdk-test' files: sdk/*. pass_filenames: false -- repo: local + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v3.0.3 hooks: - - id: ui_test + - id: prettier + args: ['--config', 'ui/.prettierrc.json', './ui'] + - repo: local + hooks: + - id: ui_test name: ui_test language: system entry: bash -c 'cd ./ui; npm run test:all' diff --git a/Makefile b/Makefile index 8b2d897..26bdf13 100644 --- a/Makefile +++ b/Makefile @@ -163,6 +163,12 @@ ui-run-dev: ## Run the ui application with hot reload ui-test: ## Test ui site @cd ui/; npm run test:all +ui-test-e2e: + @cd ui/; npx playwright test ui/playwright + +ui-test-e2e-headed: + @cd ui/; npx playwright test ui/playwright --ui + # UI Release -------------------- ## ui-create-static-out: diff --git a/ui/playwright/auth.setup.ts b/ui/playwright/auth.setup.ts index e8f7a23..d4ede69 100644 --- a/ui/playwright/auth.setup.ts +++ b/ui/playwright/auth.setup.ts @@ -1,48 +1,30 @@ -import { test as setup, expect } from '@playwright/test'; -import { SecretsManager } from 'aws-sdk' +import { test as setup, expect } from '@playwright/test' +import { domain, getSecretValue } from './utils' -const client = new SecretsManager({ region: process.env.AWS_REGION }) -const authFile = 'playwright/.auth/user.json'; -const domain = process.env.DOMAIN; +const authFile = 'playwright/.auth/user.json' const secretName = `${process.env.RESOURCE_PREFIX}_UI_TEST_USER` - -export async function getSecretValue( - secretName: string -): Promise { - return new Promise((resolve, reject) => { - client.getSecretValue({ SecretId: secretName }, function (err, data) { - if (err) { - reject(err) - } else { - resolve(data.SecretString) - } - }) - }) -} - - setup('authenticate', async ({ page }) => { - const secret = JSON.parse(await getSecretValue(secretName) as string) - await page.goto(domain); - await page.goto(`${domain}/login`); + const secret = JSON.parse((await getSecretValue(secretName)) as string) + await page.goto(domain) + await page.goto(`${domain}/login`) - await page.locator('[data-testid="login-link"]').click(); + await page.locator('[data-testid="login-link"]').click() - await page.locator('[placeholder="Username"]').nth(1).click(); + await page.locator('[placeholder="Username"]').nth(1).click() - await page.locator('[placeholder="Password"]').nth(1).click(); + await page.locator('[placeholder="Password"]').nth(1).click() - await page.locator('[placeholder="Password"]').nth(1).fill(`${secret['password']}`); + await page.locator('[placeholder="Password"]').nth(1).fill(`${secret['password']}`) - await page.locator('[placeholder="Username"]').nth(1).click(); + await page.locator('[placeholder="Username"]').nth(1).click() - await page.locator('[placeholder="Username"]').nth(1).click(); + await page.locator('[placeholder="Username"]').nth(1).click() - await page.locator('[placeholder="Username"]').nth(1).fill(`${secret['username']}`); + await page.locator('[placeholder="Username"]').nth(1).fill(`${secret['username']}`) - await page.locator('text=Sign in').nth(3).click(); - await expect(page).toHaveURL(domain); + await page.locator('text=Sign in').nth(3).click() + await expect(page).toHaveURL(domain) - await page.context().storageState({ path: authFile }); -}); \ No newline at end of file + await page.context().storageState({ path: authFile }) +}) diff --git a/ui/playwright/test-data-flow.spec.ts b/ui/playwright/test-data-flow.spec.ts index 9c8c3cb..d0926e3 100644 --- a/ui/playwright/test-data-flow.spec.ts +++ b/ui/playwright/test-data-flow.spec.ts @@ -1,87 +1,91 @@ -import { test, expect } from '@playwright/test'; -import { v4 } from 'uuid'; -import fs from 'fs'; +/* eslint-disable testing-library/prefer-screen-queries */ +import { test, expect } from '@playwright/test' +import { v4 } from 'uuid' +import fs from 'fs' + +import { domain } from './utils' -const domain = process.env.DOMAIN; const datasetName = `ui_test_dataset_${v4().replace('-', '_').slice(0, 8)}` const filePath = 'playwright/gapminder.csv' const downloadPath = `playwright/.downloads/${datasetName}` test('test', async ({ page }) => { - await page.goto(domain); - - // Create a schema - await page.locator('div[role="button"]:has-text("Create Schema")').click(); - await expect(page).toHaveURL(`${domain}/schema/create`); - await page.locator('[data-testid="field-level"]').selectOption('PUBLIC'); - await page.locator('[data-testid="field-layer"]').selectOption('default'); - await page.locator('[data-testid="field-domain"]').click(); - await page.locator('[data-testid="field-domain"]').fill('ui_test_domain'); - await page.locator('[data-testid="field-title"]').click(); - await page.locator('[data-testid="field-title"]').fill(datasetName); - await page.locator('[data-testid="field-file"]').click(); - await page.locator('[data-testid="field-file"]').setInputFiles(filePath); - await page.locator('[data-testid="submit"]').click(); - await page.locator('input[name="ownerEmail"]').click(); - await page.locator('input[name="ownerEmail"]').fill('ui_test@email.com'); - await page.locator('input[name="ownerName"]').click(); - await page.locator('input[name="ownerName"]').fill('ui_test'); - await page.locator('button:has-text("Create Schema")').click(); - // @ts-ignore - const schemaCreatedElement = await page.waitForSelector('.MuiAlertTitle-root', { text: 'Schema Created' }); - - expect(await schemaCreatedElement.innerText()).toEqual('Schema Created'); + await page.goto(domain) - // Upload a dataset - await page.getByRole('button', { name: 'Upload data' }).click(); - await page.getByTestId('select-layer').getByRole('combobox').click(); - await page.getByRole('option', { name: 'default' }).click(); - await page.getByTestId('select-domain').getByRole('combobox').click(); - await page.getByRole('option', { name: 'ui_test_domain' }).click(); - await page.getByTestId('select-dataset').getByRole('combobox').click(); - await page.getByRole('option', { name: datasetName }).click(); - await page.getByTestId('upload').click(); - await page.getByTestId('upload').setInputFiles(filePath); - await page.getByTestId('submit').click(); + // Create a schema + await page.locator('div[role="button"]:has-text("Create Schema")').click() + await expect(page).toHaveURL(`${domain}/schema/create`) + await page.locator('[data-testid="field-level"]').selectOption('PUBLIC') + await page.locator('[data-testid="field-layer"]').selectOption('default') + await page.locator('[data-testid="field-domain"]').click() + await page.locator('[data-testid="field-domain"]').fill('ui_test_domain') + await page.locator('[data-testid="field-title"]').click() + await page.locator('[data-testid="field-title"]').fill(datasetName) + await page.locator('[data-testid="field-file"]').click() + await page.locator('[data-testid="field-file"]').setInputFiles(filePath) + await page.locator('[data-testid="submit"]').click() + await page.locator('input[name="ownerEmail"]').click() + await page.locator('input[name="ownerEmail"]').fill('ui_test@email.com') + await page.locator('input[name="ownerName"]').click() + await page.locator('input[name="ownerName"]').fill('ui_test') + await page.locator('button:has-text("Create Schema")').click() + const schemaCreatedElement = await page.waitForSelector('.MuiAlertTitle-root') + expect(await schemaCreatedElement.innerText()).toEqual('Schema Created') - expect(await page.getByText('Data uploaded successfully').textContent()).toEqual('Status: Data uploaded successfully') + // Upload a dataset + await page.getByRole('button', { name: 'Upload data' }).click() + await page.getByTestId('select-layer').getByRole('combobox').click() + await page.getByRole('option', { name: 'default' }).click() + await page.getByTestId('select-domain').getByRole('combobox').click() + await page.getByRole('option', { name: 'ui_test_domain' }).click() + await page.getByTestId('select-dataset').getByRole('combobox').click() + await page.getByRole('option', { name: datasetName }).click() + await page.getByTestId('upload').click() + await page.getByTestId('upload').setInputFiles(filePath) + await page.getByTestId('submit').click() - // Download the dataset - await page.getByRole('button', { name: 'Download data' }).click(); - await page.getByTestId('select-layer').getByRole('combobox').click(); - await page.getByRole('option', { name: 'default' }).click(); - await page.getByTestId('select-domain').getByRole('combobox').click(); - await page.getByRole('option', { name: 'ui_test_domain' }).click(); - await page.getByTestId('select-dataset').getByRole('combobox').click(); - await page.getByRole('option', { name: datasetName }).click(); - await page.getByTestId('submit').click(); - await page.locator('div').filter({ hasText: 'Row Limit' }).locator('div').nth(1).click(); - await page.getByPlaceholder('30').fill('200'); - const downloadPromise = page.waitForEvent('download'); - await page.getByRole('button', { name: 'Download', exact: true }).click(); - const download = await downloadPromise; - await download.saveAs(downloadPath) + expect(await page.getByText('Data uploaded successfully').textContent()).toEqual( + 'Status: Data uploaded successfully', + ) - expect(fs.existsSync(downloadPath)).toBeTruthy() + // Download the dataset + await page.getByRole('button', { name: 'Download data' }).click() + await page.getByTestId('select-layer').getByRole('combobox').click() + await page.getByRole('option', { name: 'default' }).click() + await page.getByTestId('select-domain').getByRole('combobox').click() + await page.getByRole('option', { name: 'ui_test_domain' }).click() + await page.getByTestId('select-dataset').getByRole('combobox').click() + await page.getByRole('option', { name: datasetName }).click() + await page.getByTestId('submit').click() + await page.locator('div').filter({ hasText: 'Row Limit' }).locator('div').nth(1).click() + await page.getByPlaceholder('30').fill('200') + const downloadPromise = page.waitForEvent('download') + await page.getByRole('button', { name: 'Download', exact: true }).click() + const download = await downloadPromise + await download.saveAs(downloadPath) - fs.rm(downloadPath, (err) => { - err ? console.error(err) : console.log("Download deleted") - }) + expect(fs.existsSync(downloadPath)).toBeTruthy() - // Delete the dataset - await page.getByRole('button', { name: 'Delete data' }).click(); - await page.locator('div[role="button"]:has-text("Delete data")').click(); - await page.getByTestId('select-layer').getByRole('combobox').click(); - await page.getByRole('option', { name: 'default' }).click(); - await page.getByTestId('select-domain').getByRole('combobox').click(); - await page.getByRole('option', { name: 'ui_test_domain' }).click(); - await page.getByTestId('select-dataset').getByRole('combobox').click(); - await page.getByRole('option', { name: datasetName }).click(); - await page.getByTestId('submit').click(); + fs.rm(downloadPath, (err) => { + err ? console.error(err) : console.log('Download deleted') + }) - // @ts-ignore - const datasetDeletedElement = await page.waitForSelector('.MuiAlertTitle-root', { text: `Dataset deleted: default/ui_test_domain/${datasetName}` }); + // Delete the dataset + await page.getByRole('button', { name: 'Delete data' }).click() + await page.locator('div[role="button"]:has-text("Delete data")').click() + await page.getByTestId('select-layer').getByRole('combobox').click() + await page.getByRole('option', { name: 'default' }).click() + await page.getByTestId('select-domain').getByRole('combobox').click() + await page.getByRole('option', { name: 'ui_test_domain' }).click() + await page.getByTestId('select-dataset').getByRole('combobox').click() + await page.getByRole('option', { name: datasetName }).click() + await page.getByTestId('submit').click() - expect(await datasetDeletedElement.innerText()).toEqual(`Dataset deleted: default/ui_test_domain/${datasetName}`); + const datasetDeletedElement = await page.waitForSelector('.MuiAlertTitle-root', { + text: `Dataset deleted: default/ui_test_domain/${datasetName}`, + }) -}); \ No newline at end of file + expect(await datasetDeletedElement.innerText()).toEqual( + `Dataset deleted: default/ui_test_domain/${datasetName}`, + ) +}) diff --git a/ui/playwright/test-homepage.spec.ts b/ui/playwright/test-homepage.spec.ts index eb4bedf..edfa8c8 100644 --- a/ui/playwright/test-homepage.spec.ts +++ b/ui/playwright/test-homepage.spec.ts @@ -1,18 +1,19 @@ -import { test, expect } from '@playwright/test'; +/* eslint-disable testing-library/prefer-screen-queries */ +import { test } from '@playwright/test' -const domain = process.env.DOMAIN +import { domain } from './utils' test('test', async ({ page }) => { - await page.goto(domain); - await page.getByRole('button', { name: 'Create User' }).click(); - await page.getByRole('button', { name: 'Modify User' }).click(); - await page.getByRole('button', { name: 'Download data' }).click(); - await page.getByRole('button', { name: 'Upload data' }).click(); - await page.getByRole('button', { name: 'Create Schema' }).click(); - await page.getByRole('button', { name: 'Task Status' }).click(); - await page.getByRole('link', { name: 'Home' }).click(); - await page.getByRole('link', { name: 'Create User' }).nth(1).click(); - await page.getByRole('link', { name: 'Home' }).click(); - await page.getByRole('button', { name: 'account of current user' }).click(); - await page.getByText('Logout').click(); -}); \ No newline at end of file + await page.goto(domain) + await page.getByRole('button', { name: 'Create User' }).click() + await page.getByRole('button', { name: 'Modify User' }).click() + await page.getByRole('button', { name: 'Download data' }).click() + await page.getByRole('button', { name: 'Upload data' }).click() + await page.getByRole('button', { name: 'Create Schema' }).click() + await page.getByRole('button', { name: 'Task Status' }).click() + await page.getByRole('link', { name: 'Home' }).click() + await page.getByRole('link', { name: 'Create User' }).nth(1).click() + await page.getByRole('link', { name: 'Home' }).click() + await page.getByRole('button', { name: 'account of current user' }).click() + await page.getByText('Logout').click() +}) diff --git a/ui/playwright/test-user-flow.spec.ts b/ui/playwright/test-user-flow.spec.ts index 03bdc16..dafa96d 100644 --- a/ui/playwright/test-user-flow.spec.ts +++ b/ui/playwright/test-user-flow.spec.ts @@ -1,21 +1,55 @@ -import { test, expect } from '@playwright/test'; +/* eslint-disable testing-library/prefer-screen-queries */ +import { test, expect } from '@playwright/test' + +import { makeAPIRequest, generateRapidAuthToken } from './utils' + +const domain = 'http://localhost:3000' -const domain = process.env.DOMAIN; const user = `${process.env.RESOURCE_PREFIX}_ui_test_user` test('test', async ({ page }) => { - await page.goto(domain); - - // Click div[role="button"]:has-text("Modify User") - await page.locator('div[role="button"]:has-text("Modify User")').click(); - await expect(page).toHaveURL(`${domain}/subject/modify`); + await page.goto(domain) - await page.locator('[data-testid="field-user"]').selectOption({ 'label': user }) - await page.locator('[data-testid="submit-button"]').click(); + // Modify user to have data admin permissions + await page.locator('div[role="button"]:has-text("Modify User")').click() + await expect(page).toHaveURL(`${domain}/subject/modify`) + await page.locator('[data-testid="field-user"]').selectOption({ label: user }) + await page.locator('[data-testid="submit-button"]').click() + await page.getByRole('row', { name: 'DATA_ADMIN' }).getByRole('button').click() + await page.getByTestId('select-type').selectOption('DATA_ADMIN') + await page + .getByRole('row') + .filter({ hasText: 'ActionDATA_ADMIN' }) + .getByRole('button') + .click() + await page.getByTestId('submit').click() + // await expect(page).toHaveURL(/success/) - await page.getByRole('row', { name: 'DATA_ADMIN' }).getByRole('button').click(); - await page.getByTestId('select-type').selectOption('DATA_ADMIN'); - await page.getByRole('row').filter({ hasText: 'ActionDATA_ADMIN' }).getByRole('button').click(); - await page.getByTestId('submit').click(); - await expect(page).toHaveURL(/success/); -}); \ No newline at end of file + // Test unique condition where we correctly display permissions when modifying a user + // even though they might have conflicting permissions within the filtering logic + const { access_token } = await generateRapidAuthToken() + await makeAPIRequest( + 'subjects/permissions', + 'PUT', + { + subject_id: 'b10ded88-4e10-46d3-b9c7-ff6cf0526c09', + permissions: [ + 'DATA_ADMIN', + 'READ_ALL', + 'USER_ADMIN', + 'WRITE_ALL', + 'READ_DEFAULT_PROTECTED_TEST_E2E_PROTECTED', + ], + }, + `Bearer ${access_token}`, + ) + await page.locator('div[role="button"]:has-text("Modify User")').click() + await expect(page).toHaveURL(`${domain}/subject/modify`) + await page.locator('[data-testid="field-user"]').selectOption({ label: user }) + await page.locator('[data-testid="submit-button"]').click() + await page + .getByRole('row', { name: 'READ DEFAULT PROTECTED TEST_E2E_PROTECTED' }) + .getByRole('button') + .click() + await page.getByTestId('submit').click() +}) diff --git a/ui/playwright/utils.ts b/ui/playwright/utils.ts new file mode 100644 index 0000000..ef80c45 --- /dev/null +++ b/ui/playwright/utils.ts @@ -0,0 +1,58 @@ +import { SecretsManager } from 'aws-sdk' + +const client = new SecretsManager({ region: process.env.AWS_REGION }) + +const baseDomain = process.env.DOMAIN_NAME +export const domain = `https://${baseDomain.replace('/api', '')}` + +export async function makeAPIRequest( + path: string, + method: string, + body?: any, + authToken?: string, + optionalHeaders = {}, +): Promise { + const response = await fetch(`https://${baseDomain}/${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + ...optionalHeaders, + ...(authToken ? { Authorization: authToken } : {}), + }, + body: JSON.stringify(body), + }) + return response.json() +} + +export async function getSecretValue(secretName: string): Promise { + return new Promise((resolve, reject) => { + client.getSecretValue({ SecretId: secretName }, function (err, data) { + if (err) { + reject(err) + } else { + resolve(data.SecretString) + } + }) + }) +} + +export async function generateRapidAuthToken(): Promise { + const secretName = `${process.env.RESOURCE_PREFIX}_E2E_TEST_CLIENT_USER_ADMIN` + const clientId = JSON.parse((await getSecretValue(secretName)) as string)['CLIENT_ID'] + const clientSecret = JSON.parse((await getSecretValue(secretName)) as string)[ + 'CLIENT_SECRET' + ] + const credentialsSecret = btoa(`${clientId}:${clientSecret}`) + return makeAPIRequest( + 'oauth2/token', + 'POST', + { + grant_type: 'client_credentials', + client_id: clientId, + }, + `Basic ${credentialsSecret}`, + { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + ) +} diff --git a/ui/src/components/PermissionsTable/PermissionsTable.tsx b/ui/src/components/PermissionsTable/PermissionsTable.tsx index 7aa25ae..3024151 100644 --- a/ui/src/components/PermissionsTable/PermissionsTable.tsx +++ b/ui/src/components/PermissionsTable/PermissionsTable.tsx @@ -8,108 +8,126 @@ import { isDataPermission } from '@/service/permissions' import { PermissionUiResponse } from '@/service/types' import { useEffect, useState } from 'react' import { cloneDeep } from 'lodash' -import IconButton from '@mui/material/IconButton'; -import AddIcon from '@mui/icons-material/Add'; -import RemoveIcon from '@mui/icons-material/Remove'; -import Table from '@mui/material/Table'; -import TableBody from '@mui/material/TableBody'; -import TableCell from '@mui/material/TableCell'; -import TableContainer from '@mui/material/TableContainer'; -import TableHead from '@mui/material/TableHead'; -import TableRow from '@mui/material/TableRow'; - +import IconButton from '@mui/material/IconButton' +import AddIcon from '@mui/icons-material/Add' +import RemoveIcon from '@mui/icons-material/Remove' +import Table from '@mui/material/Table' +import TableBody from '@mui/material/TableBody' +import TableCell from '@mui/material/TableCell' +import TableContainer from '@mui/material/TableContainer' +import TableHead from '@mui/material/TableHead' +import TableRow from '@mui/material/TableRow' type ActionType = z.infer type PermissionType = z.infer type SensitivityType = z.infer - -const PermissionsTable = ({ permissionsListData, fieldArrayReturn }: { permissionsListData: PermissionUiResponse, fieldArrayReturn: FieldValues }) => { - +const PermissionsTable = ({ + permissionsListData, + fieldArrayReturn, + isModifyPage = false, +}: { + permissionsListData: PermissionUiResponse + fieldArrayReturn: FieldValues + isModifyPage?: boolean +}) => { const [filteredPermissionsListData, setFilteredPermissionsListData] = useState({}) const [permissionsAtMax, setPermissionsAtMax] = useState(false) - const removePermissionAsAnOption = (permission: PermissionType, permissionsList: PermissionUiResponse) => { - const { type, layer, sensitivity, domain } = permission; - const typeList = permissionsList[type]; - const layerList = typeList?.[layer]; - const sensitivityList = layerList?.[sensitivity]; + const removePermissionAsAnOption = ( + permission: PermissionType, + permissionsList: PermissionUiResponse, + ) => { + const { type, layer, sensitivity, domain } = permission + const typeList = permissionsList[type] + const layerList = typeList?.[layer] + const sensitivityList = layerList?.[sensitivity] switch (true) { // Scenario for protected permission case Boolean(domain): // Remove the domain if (domain in sensitivityList) { - delete sensitivityList[domain]; + delete sensitivityList[domain] } // Remove the sensitivity if there are no domains left if (!Object.keys(sensitivityList)?.length) { - delete layerList[sensitivity]; + delete layerList[sensitivity] // Remove the layer if there are no sensitivities left if (!Object.keys(layerList)?.length) { - delete typeList[layer]; + delete typeList[layer] } } - break; + break case Boolean(sensitivity): // Remove the sensitivity if (sensitivity in layerList) { - delete layerList[sensitivity]; + delete layerList[sensitivity] } // Remove the layer if there are no sensitivities left - if (!Object.keys(layerList)?.length || sensitivity === "ALL") { - delete typeList[layer]; + if (!Object.keys(layerList)?.length || sensitivity === 'ALL') { + delete typeList[layer] // Remove the type if there are no layers left - if (!Object.keys(typeList)?.length || layer === "ALL") { - delete permissionsList[type]; + if (!Object.keys(typeList)?.length || layer === 'ALL') { + delete permissionsList[type] } } - break; + break - // Scenario for admin permissions + // Scenario for admin permissions default: - delete permissionsList[type]; - break; + delete permissionsList[type] + break } - return permissionsList; - }; - + return permissionsList + } const { fields, append, remove } = fieldArrayReturn const { control, trigger, watch, reset, setError, setValue } = useForm({ - resolver: zodResolver(Permission) + resolver: zodResolver(Permission), }) // Remove any of the selected permissions from being an option useEffect(() => { let amendedPermissions = cloneDeep(permissionsListData) - fields.forEach((permission) => { - amendedPermissions = removePermissionAsAnOption(permission, amendedPermissions) - }) + // Handle the unique case where if the user has conflicting permissions that would not be allowed + // by the removePermissionAsAnOption function, we gracefully handle this error + try { + fields.forEach((permission) => { + amendedPermissions = removePermissionAsAnOption(permission, amendedPermissions) + }) + } catch (error) { + // Do not allow for this case when creating a user + if (!isModifyPage) { + throw error + } + } setFilteredPermissionsListData(amendedPermissions) - - }, [fields, permissionsListData]); + }, [fields, permissionsListData]) // Set Permissions at max useEffect(() => { if (Object.keys(filteredPermissionsListData).length === 0) { setPermissionsAtMax(true) - } - else { + } else { setPermissionsAtMax(false) } }, [filteredPermissionsListData]) - - const generateOptions = (items) => items.map((item) => { - return - }) + const generateOptions = (items) => + items.map((item) => { + return ( + + ) + }) return ( @@ -124,157 +142,191 @@ const PermissionsTable = ({ permissionsListData, fieldArrayReturn }: { permissio - {(fields || []).map((item, index) => - ( - ( + - remove(index)} - > - - - - - {item.type} - - - {item.layer} - - - {item.sensitivity} - - - {item.domain} - - ) - )} - {!permissionsAtMax && - - { - const result = trigger(undefined, { shouldFocus: true }); - if (result) { - const permissionToAdd = watch() - // Triggers an error if the domain is not set for protected sensitivity - if (isDataPermission(permissionToAdd) && permissionToAdd.sensitivity === "PROTECTED" && permissionToAdd.domain === undefined) { - setError("domain", { type: "custom", message: "Required" }); - } - else { - append(permissionToAdd) - reset({ - type: undefined, - layer: undefined, - sensitivity: undefined, - domain: undefined, - }) + + remove(index)}> + + + + + {item.type} + + + {item.layer} + + + + {item.sensitivity} + + + + {item.domain} + + + ))} + {!permissionsAtMax && ( + + + { + const result = trigger(undefined, { shouldFocus: true }) + if (result) { + const permissionToAdd = watch() + // Triggers an error if the domain is not set for protected sensitivity + if ( + isDataPermission(permissionToAdd) && + permissionToAdd.sensitivity === 'PROTECTED' && + permissionToAdd.domain === undefined + ) { + setError('domain', { type: 'custom', message: 'Required' }) + } else { + append(permissionToAdd) + reset({ + type: undefined, + layer: undefined, + sensitivity: undefined, + domain: undefined, + }) + } } + }} + > + + + + + ( + + )} + /> + + + + isDataPermission(watch()) && ( + + ) + } + /> + + + + isDataPermission(watch()) && + watch('layer') && ( + + ) } - }} - > - - - - - ( - - )} - /> - - - ( - isDataPermission(watch()) && - - )} - /> - - - ( - isDataPermission(watch()) && watch('layer') && - - ) - } - /> - - - ( - isDataPermission(watch()) && watch('sensitivity') === 'PROTECTED' && - ) - } - /> - - } + /> + + + + isDataPermission(watch()) && + watch('sensitivity') === 'PROTECTED' && ( + + ) + } + /> + + + )} - + ) } diff --git a/ui/src/pages/subject/modify/[subjectId].tsx b/ui/src/pages/subject/modify/[subjectId].tsx index a58aef1..5d64aef 100644 --- a/ui/src/pages/subject/modify/[subjectId].tsx +++ b/ui/src/pages/subject/modify/[subjectId].tsx @@ -4,12 +4,12 @@ import AccountLayout from '@/components/Layout/AccountLayout' import { getPermissionsListUi, getSubjectPermissions, - updateSubjectPermissions + updateSubjectPermissions, } from '@/service' import { extractPermissionNames } from '@/service/permissions' import { UpdateSubjectPermissionsBody, - UpdateSubjectPermissionsResponse + UpdateSubjectPermissionsResponse, } from '@/service/types' import { Alert, Typography, LinearProgress } from '@mui/material' import { useMutation, useQuery } from '@tanstack/react-query' @@ -18,7 +18,6 @@ import { useEffect } from 'react' import { useForm, useFieldArray } from 'react-hook-form' import PermissionsTable from '@/components/PermissionsTable/PermissionsTable' - function SubjectModifyPage() { const router = useRouter() const { subjectId, name } = router.query @@ -27,21 +26,21 @@ function SubjectModifyPage() { const fieldArrayReturn = useFieldArray({ control, - name: 'permissions' - }); + name: 'permissions', + }) - const { append } = fieldArrayReturn; + const { append } = fieldArrayReturn const { isLoading: isPermissionsListDataLoading, data: permissionsListData, - error: permissionsListDataError + error: permissionsListDataError, } = useQuery(['permissionsList'], getPermissionsListUi) const { isLoading: isSubjectPermissionsLoading, data: subjectPermissionsData, - error: subjectPermissionsError + error: subjectPermissionsError, } = useQuery(['subjectPermissions', subjectId], getSubjectPermissions) useEffect(() => { @@ -59,7 +58,7 @@ function SubjectModifyPage() { mutationFn: updateSubjectPermissions, onSuccess: () => { router.push({ pathname: `/subject/modify/success/${subjectId}`, query: { name } }) - } + }, }) if (isPermissionsListDataLoading || isSubjectPermissionsLoading) { @@ -77,20 +76,16 @@ function SubjectModifyPage() { return (
{ - const permissions = data.permissions.map((permission) => extractPermissionNames(permission, permissionsListData)) - await mutate( - { subject_id: subjectId as string, permissions }) + const permissions = data.permissions.map((permission) => + extractPermissionNames(permission, permissionsListData), + ) + await mutate({ subject_id: subjectId as string, permissions }) })} noValidate > + } @@ -99,7 +94,11 @@ function SubjectModifyPage() { Modify Subject Select permissions for {name} - + {error && ( {error?.message} From c1fcdbcf8fe4086eacff8954e2c6c58f2fdc9aac Mon Sep 17 00:00:00 2001 From: Toby Drane Date: Fri, 1 Sep 2023 16:53:34 +0100 Subject: [PATCH 17/17] remove hardcoded subject id --- ui/playwright/test-user-flow.spec.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/ui/playwright/test-user-flow.spec.ts b/ui/playwright/test-user-flow.spec.ts index dafa96d..2c79ed4 100644 --- a/ui/playwright/test-user-flow.spec.ts +++ b/ui/playwright/test-user-flow.spec.ts @@ -1,9 +1,7 @@ /* eslint-disable testing-library/prefer-screen-queries */ import { test, expect } from '@playwright/test' -import { makeAPIRequest, generateRapidAuthToken } from './utils' - -const domain = 'http://localhost:3000' +import { makeAPIRequest, generateRapidAuthToken, domain } from './utils' const user = `${process.env.RESOURCE_PREFIX}_ui_test_user` @@ -11,7 +9,7 @@ test('test', async ({ page }) => { await page.goto(domain) // Modify user to have data admin permissions - await page.locator('div[role="button"]:has-text("Modify User")').click() + https: await page.locator('div[role="button"]:has-text("Modify User")').click() await expect(page).toHaveURL(`${domain}/subject/modify`) await page.locator('[data-testid="field-user"]').selectOption({ label: user }) await page.locator('[data-testid="submit-button"]').click() @@ -28,20 +26,22 @@ test('test', async ({ page }) => { // Test unique condition where we correctly display permissions when modifying a user // even though they might have conflicting permissions within the filtering logic const { access_token } = await generateRapidAuthToken() + const url = page.url() + const subjectId = url.split('/').pop() await makeAPIRequest( 'subjects/permissions', 'PUT', { - subject_id: 'b10ded88-4e10-46d3-b9c7-ff6cf0526c09', + subject_id: subjectId, permissions: [ 'DATA_ADMIN', 'READ_ALL', 'USER_ADMIN', 'WRITE_ALL', - 'READ_DEFAULT_PROTECTED_TEST_E2E_PROTECTED', - ], + 'READ_DEFAULT_PROTECTED_TEST_E2E_PROTECTED' + ] }, - `Bearer ${access_token}`, + `Bearer ${access_token}` ) await page.locator('div[role="button"]:has-text("Modify User")').click() await expect(page).toHaveURL(`${domain}/subject/modify`)