Skip to content

Commit 62ba4bc

Browse files
[CDAPI-78]: added unit tests
1 parent ae7f964 commit 62ba4bc

11 files changed

Lines changed: 577 additions & 96 deletions

File tree

.github/workflows/preview-env.yaml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -195,18 +195,23 @@ jobs:
195195
echo "url = ${{ steps.names.outputs.preview_url }}"
196196
197197
# ---------- Handle mock endpoints ----------
198+
- name: Get Secrets for mocks
199+
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
200+
with:
201+
secret-ids: |
202+
/cds/pathology/dev/jwks/secret
203+
name-transformation: lowercase
204+
198205
- name: Create or update mock Lambda (on open/sync/reopen)
199206
if: github.event.action != 'closed'
200207
env:
201208
TOKEN_EXPIRY_TIME: ${{ secrets.TOKEN_LIFETIME }}
202-
JWT_ALGORITHMS: "RS512"
203209
AUTH_URL: "${{ steps.names.outputs.mock_preview_url }}/oauth2/token"
204-
JWKS_SECRET: "${{ secrets.JWKS_SECRET }}"
210+
JWKS_SECRET: ${{ env._cds_pathology_dev_jwks_secret }}
205211
PUBLIC_KEY_URL: "https://example.com"
206212
TOKEN_TABLE_NAME: "mock_services_dev"
207213
run: |
208214
cd mocks/target/
209-
JWKS_SECRET="${JWKS_SECRET_NAME:-/cds/pathology/dev/jwks/secret}"
210215
MFN="${{ steps.names.outputs.mock_function_name }}"
211216
SAFE="${{ steps.branch.outputs.safe }}"
212217
TOKEN_LIFETIME="${TOKEN_EXPIRY_TIME:-15m}"

.github/workflows/stage-2-test.yaml

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,20 @@ jobs:
4040
python-version: ${{ inputs.python_version }}
4141
- name: "Run unit test suite"
4242
run: make test-unit
43-
- name: "Upload unit test results"
43+
- name: "Upload unit test results for pathology-api"
4444
if: always()
4545
uses: actions/upload-artifact@v6
4646
with:
4747
name: unit-test-results
4848
path: pathology-api/test-artefacts/
4949
retention-days: 30
50+
- name: "Upload unit test results for mocks"
51+
if: always()
52+
uses: actions/upload-artifact@v6
53+
with:
54+
name: mock-unit-test-results
55+
path: mocks/test-artefacts/
56+
retention-days: 30
5057
- name: "Publish unit test results to summary"
5158
if: always()
5259
uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 # v2.4
@@ -191,19 +198,35 @@ jobs:
191198
with:
192199
path: pathology-api/test-artefacts/
193200
merge-multiple: false
201+
- name: "Download mock test coverage artefacts"
202+
uses: actions/download-artifact@v7
203+
with:
204+
name: mock-unit-test-results
205+
path: mocks/test-artefacts/
206+
merge-multiple: false
194207
- name: "Merge coverage data"
195208
run: make test-coverage
196209
- name: "Rename coverage XML with unique name"
197210
run: |
198211
cd pathology-api/test-artefacts
199212
mv coverage-merged.xml ${{ needs.create-coverage-name.outputs.coverage-name }}.xml
213+
cd ../..
214+
cd mocks/test-artefacts
215+
mv coverage-merged.xml ${{ needs.create-coverage-name.outputs.coverage-name }}-mocks.xml
200216
- name: "Upload combined coverage report"
201217
if: always()
202218
uses: actions/upload-artifact@v6
203219
with:
204220
name: ${{ needs.create-coverage-name.outputs.coverage-name }}
205221
path: pathology-api/test-artefacts
206222
retention-days: 30
223+
- name: "Upload mocks coverage report"
224+
if: always()
225+
uses: actions/upload-artifact@v6
226+
with:
227+
name: ${{ needs.create-coverage-name.outputs.coverage-name }}-mocks
228+
path: mocks/test-artefacts
229+
retention-days: 30
207230

208231
sonarcloud-analysis:
209232
name: "SonarCloud Analysis"
@@ -221,6 +244,11 @@ jobs:
221244
with:
222245
name: ${{ needs.create-coverage-name.outputs.coverage-name }}
223246
path: coverage-reports/
247+
- name: "Download mock coverage report"
248+
uses: actions/download-artifact@v7
249+
with:
250+
name: ${{ needs.create-coverage-name.outputs.coverage-name }}-mocks
251+
path: coverage-reports/
224252
- name: "SonarCloud Scan"
225253
uses: SonarSource/sonarqube-scan-action@v7
226254
env:
@@ -229,4 +257,4 @@ jobs:
229257
args: >
230258
-Dsonar.organization=${{ vars.SONAR_ORGANISATION_KEY }}
231259
-Dsonar.projectKey=${{ vars.SONAR_PROJECT_KEY }}
232-
-Dsonar.python.coverage.reportPaths=coverage-reports/${{ needs.create-coverage-name.outputs.coverage-name }}.xml
260+
-Dsonar.python.coverage.reportPaths=coverage-reports/${{ needs.create-coverage-name.outputs.coverage-name }}.xml,coverage-reports/${{ needs.create-coverage-name.outputs.coverage-name }}-mocks.xml

Makefile

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -92,17 +92,6 @@ deploy: clean-docker build-images # Deploy the project artefact to the target en
9292
$(docker) run --name api-gateway-mock -p 5002:5000 --network $(dockerNetwork) -d localhost/api-gateway-mock-image
9393
$(docker) run --name api-gateway-mock-2 -p 5005:5000 -e TARGET_CONTAINER='MOCKS' --network $(dockerNetwork) -d localhost/api-gateway-mock-image
9494

95-
# .PHONY: quick-deploy-mock
96-
# quick-deploy-mock: build-mocks
97-
# @echo "Stopping mocks container..."
98-
# @$(docker) stop mocks || echo "No mocks container currently running."
99-
100-
# @echo "Removing mocks container..."
101-
# @$(docker) rm mocks || echo "No mocks container currently exists."
102-
103-
# @$(docker) run --platform linux/amd64 --name mocks -p 5003:8080 --network $(dockerNetwork) -d localhost/mocks-image
104-
105-
10695
clean-artifacts:
10796
@echo "Removing build artefacts..."
10897
@rm -rf infrastructure/images/pathology-api/resources/build/

infrastructure/environments/preview/handler.py

Lines changed: 0 additions & 42 deletions
This file was deleted.

mocks/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
/target
22
/dist
33
/.coverage
4+
/test-artefacts

mocks/README.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,37 @@
22

33
## Testing
44

5+
There are currently only unit tests for the mocks
6+
57
### Continuous
68

9+
All tests run automatically in the CI/CD pipeline on every push and pull request. **Any test failure at any level will cause the pipeline to fail and prevent the PR from being merged.**
10+
11+
Additionally, code coverage is collected from all test types, merged, and analyzed by SonarCloud. PRs must meet minimum coverage thresholds to pass quality gates.
12+
713
### Quick Test Commands
814

15+
```bash
16+
# Run all unit, contract, and schema tests
17+
poetry run pytest -v
18+
```
19+
920
## Project Structure
1021

11-
### Implementing new mock
22+
```text
23+
mocks
24+
├── src
25+
│ └── apim_mock
26+
│ ├── __init__.py
27+
│ ├── auth_check.py
28+
│ └── handler.py
29+
├── tests
30+
│ └── apim_mock
31+
├── README.md
32+
├── lambda_handler.py
33+
└── pyproject.toml
34+
```
35+
36+
### Implementing a new mock
37+
38+
Create a new package under the `src` folder

mocks/src/apim_mock/handler.py

Lines changed: 36 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
AUTH_URL = os.environ.get("AUTH_URL", "https://api.service.nhs.uk/oauth2/token")
1717
PUBLIC_KEY_URL = os.environ.get("PUBLIC_KEY_URL", "https://example.com")
1818
API_KEY = os.environ.get("API_KEY", "api_key")
19-
TOKEN_TABLE_NAME = os.environ.get("TOKEN_TABLE_NAME", "")
19+
TOKEN_TABLE_NAME = os.environ.get("TOKEN_TABLE_NAME", "table_name")
2020
BRANCH_NAME = os.environ.get("DDB_INDEX_TAG", "")
2121

2222

@@ -40,7 +40,7 @@ def handle_request(payload: dict[str, Any]) -> dict[str, Any]:
4040

4141
_validate_assertions(assertions)
4242

43-
token = generate_random_token()
43+
token = _generate_random_token()
4444

4545
item = {
4646
"access_token": token,
@@ -61,6 +61,39 @@ def handle_request(payload: dict[str, Any]) -> dict[str, Any]:
6161
return response
6262

6363

64+
def _validate_payload(payload: dict[str, Any]) -> None:
65+
if not payload.get("grant_type"):
66+
raise ValueError("grant_type is missing")
67+
client_assertion_type = payload.get("client_assertion_type")
68+
if (
69+
not client_assertion_type
70+
or client_assertion_type[0]
71+
!= "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
72+
):
73+
raise ValueError(
74+
"Missing or invalid client_assertion_type - "
75+
"must be 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'"
76+
)
77+
if not payload.get("client_assertion"):
78+
raise ValueError("Missing client_assertion")
79+
80+
81+
def _get_jwt_headers(client_assertion: str) -> dict[str, Any]:
82+
unverified_headers = jwt.get_unverified_header(client_assertion) # noqa: S5659
83+
_logger.debug("unverified headers: %s", unverified_headers)
84+
algorithm = unverified_headers.get("alg", "")
85+
if algorithm not in JWT_ALGORITHMS:
86+
raise ValueError(
87+
"Invalid 'alg' header in client_assertion JWT - unsupported JWT algorithm"
88+
" - must be 'RS512'"
89+
)
90+
91+
if not unverified_headers.get("kid"):
92+
raise ValueError("Missing 'kid' header in client_assertion JWT")
93+
94+
return unverified_headers
95+
96+
6497
def _get_jwk_key_from_url_by_kid(kid: str) -> Any:
6598

6699
# TODO - once we have our endpoint setup we can query it here
@@ -100,23 +133,6 @@ def _get_jwk_key_from_url_by_kid(kid: str) -> Any:
100133
return key
101134

102135

103-
def _validate_payload(payload: dict[str, Any]) -> None:
104-
if not payload.get("grant_type"):
105-
raise ValueError("grant_type is missing")
106-
client_assertion_type = payload.get("client_assertion_type")
107-
if (
108-
not client_assertion_type
109-
or client_assertion_type[0]
110-
!= "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
111-
):
112-
raise ValueError(
113-
"Missing or invalid client_assertion_type - "
114-
"must be 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'"
115-
)
116-
if not payload.get("client_assertion"):
117-
raise ValueError("Missing client_assertion")
118-
119-
120136
def _validate_assertions(assertions: dict[str, Any]) -> None:
121137
expected_api_key = API_KEY
122138

@@ -142,30 +158,14 @@ def _validate_assertions(assertions: dict[str, Any]) -> None:
142158
raise ValueError("Missing exp claim in assertions")
143159

144160

145-
def _get_jwt_headers(client_assertion: str) -> dict[str, Any]:
146-
unverified_headers = jwt.get_unverified_header(client_assertion)
147-
_logger.debug("unverified headers: %s", unverified_headers)
148-
algorithm = unverified_headers.get("alg", "")
149-
if algorithm not in JWT_ALGORITHMS:
150-
raise ValueError(
151-
"Invalid 'alg' header in client_assertion JWT - unsupported JWT algorithm"
152-
" - must be 'RS512'"
153-
)
154-
155-
if not unverified_headers.get("kid"):
156-
raise ValueError("Missing 'kid' header in client_assertion JWT")
157-
158-
return unverified_headers
159-
160-
161161
def check_valid_uuid4(string: str) -> bool:
162162
uuid_regex = (
163163
r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
164164
)
165165
return re.match(uuid_regex, string) is not None
166166

167167

168-
def generate_random_token() -> str:
168+
def _generate_random_token() -> str:
169169
return "".join(
170170
secrets.choice(
171171
"-._~+/" + string.ascii_uppercase + string.ascii_lowercase + string.digits

0 commit comments

Comments
 (0)