Skip to content

Commit

Permalink
User Tools Utility (#36)
Browse files Browse the repository at this point in the history
<!-- markdownlint-disable-next-line first-line-heading -->
## Description

<!-- Describe your changes in detail. -->
This adds in user tool support, so users can be managed from a JSON file
rather than the code directly.

## Context

<!-- Why is this change required? What problem does it solve? -->
Allows for tidy user management, in an easy to maintain format rather
than in code.

## Type of changes

<!-- What types of changes does your code introduce? Put an `x` in all
the boxes that apply. -->

- [ ] Refactoring (non-breaking change)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would change existing
functionality)
- [ ] Bug fix (non-breaking change which fixes an issue)

## Checklist

<!-- Go over all the following points, and put an `x` in all the boxes
that apply. -->

- [x] I am familiar with the [contributing
guidelines](https://github.com/nhs-england-tools/playwright-python-blueprint/blob/main/CONTRIBUTING.md)
- [x] I have followed the code style of the project
- [x] I have added tests to cover my changes (where appropriate)
- [x] I have updated the documentation accordingly
- [ ] This PR is a result of pair or mob programming

---

## Sensitive Information Declaration

To ensure the utmost confidentiality and protect your and others
privacy, we kindly ask you to NOT including [PII (Personal Identifiable
Information) / PID (Personal Identifiable
Data)](https://digital.nhs.uk/data-and-information/keeping-data-safe-and-benefitting-the-public)
or any other sensitive data in this PR (Pull Request) and the codebase
changes. We will remove any PR that do contain any sensitive
information. We really appreciate your cooperation in this matter.

- [x] I confirm that neither PII/PID nor sensitive data are included in
this PR and the codebase changes.
  • Loading branch information
davethepunkyone authored Nov 26, 2024
1 parent 4692285 commit 7e0a8fc
Show file tree
Hide file tree
Showing 13 changed files with 290 additions and 30 deletions.
6 changes: 6 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,15 @@
"cSpell.words": [
"addopts",
"codegen",
"customisable",
"customised",
"initialise",
"Licence",
"organisation",
"pytest",
"pytestmark",
"retcode",
"ruleset",
"utilise",
"utilised",
"Utilising"
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ This blueprint also provides the following utility classes, that can be used to
|-------|-----------|
|[Axe](./docs/utility-guides/Axe.md)|Accessibility scanning using axe-core.|
|[Date Time Utility](./docs/utility-guides/DateTimeUtility.md)|Basic functionality for managing date/times.|
|NHSNumberTools|Basic tools for working with NHS numbers.|
|[NHSNumberTools](./docs/utility-guides/NHSNumberTools.md)|Basic tools for working with NHS numbers.|
|[User Tools](./docs/utility-guides/UserTools.md)|Basic user management tool.|

## Contributing

Expand Down
7 changes: 7 additions & 0 deletions docs/getting-started/2_Blueprint_File_Breakdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ This guide outlines the breakdown of this blueprint, and specifically the files
- [Directories \& Files Directly Impacting Tests](#directories--files-directly-impacting-tests)
- [`requirements.txt`](#requirementstxt)
- [`pytest.ini`](#pytestini)
- [`users.json`](#usersjson)
- [`tests/`](#tests)
- [`pages/`](#pages)
- [`utils/`](#utils)
Expand Down Expand Up @@ -36,6 +37,11 @@ This file outlines the configuration of pytest, and ultimately how Playwright al

Any configuration you want to apply to all of your test executions should be placed in this file where possible, to ensure easy maintenance.

### `users.json`

This file outlines the users you may want to use as part of your testing, and is utilised by the User Tools utility. Further information on how this file is used
can be found in the [User Tools Utility Guide](../utility-guides/UserTools.md).

### `tests/`

This directory is designed to house all of your tests intended for execution.
Expand Down Expand Up @@ -66,3 +72,4 @@ The following directories and files are specific for this repository, and may re
- `scripts/`: This directory houses the scripts used by this repository, primarily as part of the CI/CD checks.
- `tests_utils/`: This directory houses the unit tests for the utilities provided by this repository. You may want to copy these over if you want to ensure utilities are behaving as expected.
- `.editorconfig`, `.gitattributes`, `.gitignore`, `.gitleaks.toml`, `.gitleaksignore`: These files are configuration for git, and quality and security checks provided via the CI/CD checks.
- `Makefile`: This file is used to import some of the scripts for CI/CD checks, but can be customised per project if needed. The template this project is based from provides a more comprehensive example [here](https://github.com/nhs-england-tools/repository-template/blob/main/Makefile).
39 changes: 39 additions & 0 deletions docs/utility-guides/NHSNumberTools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Utility Guide: NHS Number Tools

The NHS Number Tools utility provided by this blueprint allows for the easy management of NHS numbers, and provides
common functionality that may apply to many services in relation to NHS Number management.

## Table of Contents

- [Utility Guide: NHS Number Tools](#utility-guide-nhs-number-tools)
- [Table of Contents](#table-of-contents)
- [Using the NHS Number Tools class](#using-the-nhs-number-tools-class)
- [`spaced_nhs_number()`: Return Spaced NHS Number](#spaced_nhs_number-return-spaced-nhs-number)
- [Required Arguments](#required-arguments)
- [Returns](#returns)

## Using the NHS Number Tools class

You can initialise the NHS Number Tools class by using the following code in your test file:

from utils.nhs_number_tools import NHSNumberTools

## `spaced_nhs_number()`: Return Spaced NHS Number

The `spaced_nhs_number()` method is designed to take the provided NHS number and return it in a formatted
string of the format `nnn nnn nnnn`. It's a static method so can be used in the following way:

# Return formatted NHS number
spaced_nhs_number = NHSNumberTools.spaced_nhs_number("1234567890")

### Required Arguments

The following are required for `NHSNumberTools.spaced_nhs_number()`:

| Argument | Format | Description |
| ---------- | -------------- | ------------------------- |
| nhs_number | `str` or `int` | The NHS number to format. |

### Returns

A `str` with the provided NHS number in `nnn nnn nnnn` format. For example, `NHSNumberTools.spaced_nhs_number(1234567890)` would return `123 456 7890`.
89 changes: 89 additions & 0 deletions docs/utility-guides/UserTools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Utility Guide: User Tools

The User Tools utility provided by this blueprint allows for the easy management of test users via a json file included
at the base of the repository.

## Table of Contents

- [Utility Guide: User Tools](#utility-guide-user-tools)
- [Table of Contents](#table-of-contents)
- [Using the User Tools class](#using-the-user-tools-class)
- [Managing Users](#managing-users)
- [Considering Security](#considering-security)
- [`retrieve_user()`: Retrieve User Details](#retrieve_user-retrieve-user-details)
- [Required Arguments](#required-arguments)
- [Returns](#returns)
- [Example Usage](#example-usage)

## Using the User Tools class

You can initialise the User Tools class by using the following code in your test file:

from utils.user_tools import UserTools

This module has been designed as a static class, so you do not need to instantiate it when you want to retrieve any user information.

## Managing Users

For this class, users are managed via the [users.json](../../users.json) file provided with this repository. For any new users you need to
add, the idea is to just add a new record, with any appropriate metadata you need for the user whilst they interact with your application.

For example, adding a record like so (this example shows the entire `users.json` file):

{
"Documentation User": {
"username": "DOC_USER",
"roles": ["Example Role A"],
"unique_id": 42
}
}

The data you require for these users can be completely customised for what information you need, so whilst the example shows `username`, `roles`
and `unique_id` as possible values we may want to use, this is not an exhaustive list. The key that is used (so in this example, `"Documentation User"`)
is also customisable and should be how you want to easily reference retrieving this user in your tests.

### Considering Security

An important note on managing users in this way is that passwords or security credentials should **never** be stored in the `users.json` file. These
are considered secrets, and whilst it may be convenient to store them in this file, it goes against the
[security principles outlined in the Software Engineering Quality Framework](https://github.com/NHSDigital/software-engineering-quality-framework/blob/main/practices/security.md#application-level-security).

With this in mind, it's recommended to do the following when it comes to managing these types of credentials:

- When running locally, store any secret values in a local configuration file and set this file in `.gitignore` so it is not committed to the codebase.
- When running via a CI/CD process, store any secret values in an appropriate secret store and pass the values into pytest at runtime.

## `retrieve_user()`: Retrieve User Details

The `retrieve_user()` method is designed to easily retrieve the data for a specific user entry from the `users.json` file. This is a static method,
so can be called using the following logic:

# Retrieving documentation user details from example
user_details = UserTools.retrieve_user("Documentation User")

### Required Arguments

The following are required for `UserTools.retrieve_user()`:

| Argument | Format | Description |
| -------- | ------ | ------------------------------------------------------- |
| user | `str` | The key from `users.json` for the user details required |

### Returns

A Python `dict` object that contains the values associated with the provided user argument.

### Example Usage

When using a `users.json` file as set up in the example above:

from utils.user_tools import UserTools
from playwright.sync_api import Page

def test_login(page: Page) -> None:
# Retrieving documentation user details from example
user_details = UserTools.retrieve_user("Documentation User")

# Use values to populate a form
page.get_by_role("textbox", name="Username").fill(user_details["username"])
page.get_by_role("textbox", name="ID").fill(user_details["unique_id"])
10 changes: 10 additions & 0 deletions tests_utils/resources/test_users.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"Test User": {
"username": "TEST_USER1",
"test_key": "TEST A"
},
"Test User 2": {
"username": "TEST_USER2",
"test_key": "TEST B"
}
}
18 changes: 18 additions & 0 deletions tests_utils/test_nhs_number_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import pytest
from utils.nhs_number_tools import NHSNumberTools, NHSNumberToolsException


pytestmark = [pytest.mark.utils]

def test_nhs_number_checks() -> None:
assert NHSNumberTools._nhs_number_checks("1234567890") == None

with pytest.raises(Exception, match=r'The NHS number provided \(A234567890\) is not numeric.'):
NHSNumberTools._nhs_number_checks("A234567890")

with pytest.raises(NHSNumberToolsException, match=r'The NHS number provided \(123\) is not 10 digits'):
NHSNumberTools._nhs_number_checks("123")

def test_spaced_nhs_number() -> None:
assert NHSNumberTools.spaced_nhs_number("1234567890") == "123 456 7890"
assert NHSNumberTools.spaced_nhs_number(3216549870) == "321 654 9870"
21 changes: 21 additions & 0 deletions tests_utils/test_user_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import pytest
import utils.user_tools
from utils.user_tools import UserTools, UserToolsException
from pathlib import Path


pytestmark = [pytest.mark.utils]

def test_retrieve_user(monkeypatch: object) -> None:
monkeypatch.setattr(utils.user_tools, "USERS_FILE", Path(__file__).parent / "resources" / "test_users.json")

test_user = UserTools.retrieve_user("Test User")
assert test_user["username"] == "TEST_USER1"
assert test_user["test_key"] == "TEST A"

test_user2 = UserTools.retrieve_user("Test User 2")
assert test_user2["username"] == "TEST_USER2"
assert test_user2["test_key"] == "TEST B"

with pytest.raises(UserToolsException, match=r'User \[Invalid User\] is not present in users.json'):
UserTools.retrieve_user("Invalid User")
11 changes: 11 additions & 0 deletions users.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"_comment": "This file can be used to store data on users for your application, and then pulled through using the utils.user_tools UserTools utility. The documentation for this utility explains how this file is read.",
"Example User 1": {
"username": "EXAMPLE_USER1",
"roles": ["Example Role A"]
},
"Example User 2": {
"username": "EXAMPLE_USER2",
"roles": ["Example Role B", "Example Role C"]
}
}
21 changes: 14 additions & 7 deletions utils/axe.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from pathlib import Path


logger = logging.getLogger(__name__)
AXE_PATH = Path(__file__).parent / "resources" / "axe.js"
PATH_FOR_REPORT = Path(__file__).parent.parent / "axe-reports"
DEFAULT_WCAG_RULESET = ['wcag2a', 'wcag21a', 'wcag2aa', 'wcag21aa', 'wcag22a', 'wcag22aa', 'best-practice']
Expand Down Expand Up @@ -36,13 +37,16 @@ def run(page: Page,
strict_mode (bool): [Optional] If true, raise an exception if a violation is detected. If false (default), proceed with test execution.
html_report_generated (bool): [Optional] If true (default), generates a html report for the page scanned. If false, no html report is generated.
json_report_generated (bool): [Optional] If true (default), generates a json report for the page scanned. If false, no json report is generated.
Returns:
dict: A Python dictionary with the axe-core output of the page scanned.
"""

page.evaluate(AXE_PATH.read_text(encoding="UTF-8"))

response = page.evaluate("axe." + Axe._build_run_command(ruleset) + ".then(results => {return results;})")

logging.info(f"""Axe scan summary of [{response["url"]}]: Passes = {len(response["passes"])},
logger.info(f"""Axe scan summary of [{response["url"]}]: Passes = {len(response["passes"])},
Violations = {len(response["violations"])}, Inapplicable = {len(response["inapplicable"])},
Incomplete = {len(response["incomplete"])}""")

Expand Down Expand Up @@ -81,6 +85,9 @@ def run_list(page: Page,
strict_mode (bool): [Optional] If true, raise an exception if a violation is detected. If false (default), proceed with test execution.
html_report_generated (bool): [Optional] If true (default), generates a html report for the page scanned. If false, no html report is generated.
json_report_generated (bool): [Optional] If true (default), generates a json report for the page scanned. If false, no json report is generated.
Returns:
dict: A Python dictionary with the axe-core output of all the pages scanned, with the page list used as the key for each report.
"""
results = {}
for selected_page in page_list:
Expand Down Expand Up @@ -120,24 +127,24 @@ def _create_path_for_report(filename: str) -> Path:
return PATH_FOR_REPORT / filename

@staticmethod
def _create_json_report(data: dict, filename_overide: str = "") -> None:
filename = f"{Axe._modify_filename_for_report(data["url"])}.json" if filename_overide == "" else f"{filename_overide}.json"
def _create_json_report(data: dict, filename_override: str = "") -> None:
filename = f"{Axe._modify_filename_for_report(data["url"])}.json" if filename_override == "" else f"{filename_override}.json"
full_path = Axe._create_path_for_report(filename)

with open(full_path, 'w') as file:
file.writelines(json.dumps(data))

logging.info(f"JSON report generated: {full_path}")
logger.info(f"JSON report generated: {full_path}")

@staticmethod
def _create_html_report(data: dict, filename_overide: str = "") -> None:
filename = f"{Axe._modify_filename_for_report(data["url"])}.html" if filename_overide == "" else f"{filename_overide}.html"
def _create_html_report(data: dict, filename_override: str = "") -> None:
filename = f"{Axe._modify_filename_for_report(data["url"])}.html" if filename_override == "" else f"{filename_override}.html"
full_path = Axe._create_path_for_report(filename)

with open(full_path, 'w') as file:
file.writelines(Axe._generate_html(data))

logging.info(f"HTML report generated: {full_path}")
logger.info(f"HTML report generated: {full_path}")

@staticmethod
def _generate_html(data: dict) -> str:
Expand Down
27 changes: 13 additions & 14 deletions utils/date_time_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@


class DateTimeUtils:
"""
A utility class for doing common actions with datetimes.
"""

def __init__(self):
pass

Expand All @@ -13,8 +17,7 @@ def current_datetime(format_date: str = "%d/%m/%Y %H:%M") -> str:
format_date (str): [Optional] The format to return the current datetime in. Defaults to dd/mm/yyyy hh:mm if not provided.
Returns:
current_date (str): The current datetime in the specified format.
str: The current datetime in the specified format.
"""
return datetime.now().strftime(format_date)

Expand All @@ -27,9 +30,8 @@ def format_date(date: datetime, format_date: str = "%d/%m/%Y") -> str:
format_date (str): [Optional] The format to return the datetime in. Defaults to dd/mm/yyyy if not provided.
Returns:
format_date (str): The formatted date in the specified format.
"""
str: The formatted date in the specified format.
"""
return date.strftime(format_date)

@staticmethod
Expand All @@ -41,9 +43,8 @@ def add_days(date: datetime, days: float) -> datetime:
days (float): The number of days to add to the specified date.
Returns:
new_date (datetime): The specified date plus the number of specified days (year, month, day, hour, minute, second, microsecond).
"""
datetime: The specified date plus the number of specified days (year, month, day, hour, minute, second, microsecond).
"""
return date + timedelta(days=days)

@staticmethod
Expand All @@ -54,9 +55,8 @@ def get_day_of_week_for_today(date: datetime) -> str:
date (datetime): The current date using the now function
Returns:
day_of_week (str): The day of the week relating to the specified date.
"""
str: The day of the week relating to the specified date.
"""
return date.strftime("%A")

@staticmethod
Expand All @@ -67,7 +67,6 @@ def get_a_day_of_week(date: datetime) -> str:
date (datetime): The date for which the day of the week will be returned.
Returns:
day_of_week (str): The day of the week relating to the specified date.
"""
str: The day of the week relating to the specified date.
"""
return date.strftime("%A")
Loading

0 comments on commit 7e0a8fc

Please sign in to comment.