From 500aeda018a9763cf9fc43385415b7d0fc11c708 Mon Sep 17 00:00:00 2001 From: knrdl <35548889+knrdl@users.noreply.github.com> Date: Mon, 2 Dec 2024 17:15:08 +0100 Subject: [PATCH] test: add tests #138 --- app/acme/directory/router.py | 4 +++ app/acme/nonce/router.py | 3 ++ app/acme/nonce/service.py | 2 +- tests/pytest/Dockerfile | 4 +-- tests/pytest/conftest.py | 43 +++++++++++++++++++++++++++-- tests/pytest/test_acme_account.py | 15 ++++++++++ tests/pytest/test_acme_directory.py | 29 +++++++++++++++---- tests/pytest/test_acme_nonce.py | 21 ++++++++++++-- 8 files changed, 106 insertions(+), 15 deletions(-) create mode 100644 tests/pytest/test_acme_account.py diff --git a/app/acme/directory/router.py b/app/acme/directory/router.py index 5984c2d..c5e0326 100644 --- a/app/acme/directory/router.py +++ b/app/acme/directory/router.py @@ -7,6 +7,10 @@ @api.get('/directory') async def get_directory(): + """ + See RFC 8555 7.1.1 "Directory" + """ + meta = {'website': settings.external_url} if settings.acme.terms_of_service_url: meta['termsOfService'] = settings.acme.terms_of_service_url diff --git a/app/acme/nonce/router.py b/app/acme/nonce/router.py index 8ee45ac..77f9b25 100644 --- a/app/acme/nonce/router.py +++ b/app/acme/nonce/router.py @@ -10,6 +10,9 @@ @api.head('/new-nonce', status_code=status.HTTP_200_OK) @api.get('/new-nonce', status_code=status.HTTP_204_NO_CONTENT) async def get_nonce(response: Response): + """ + See RFC 8555 7.2 "Getting a Nonce" + """ response.headers['Replay-Nonce'] = await generate() response.headers['Cache-Control'] = 'no-store' response.headers['Link'] = f'<{settings.external_url}acme/directory>;rel="index"' diff --git a/app/acme/nonce/service.py b/app/acme/nonce/service.py index 9263753..2e9ca84 100644 --- a/app/acme/nonce/service.py +++ b/app/acme/nonce/service.py @@ -20,5 +20,5 @@ async def refresh(nonce: str) -> str: old_nonce_ok = await sql.exec("""delete from nonces where id = $1""", nonce) == 'DELETE 1' await sql.exec("""insert into nonces (id) values ($1)""", new_nonce) if not old_nonce_ok: - raise ACMEException(status_code=status.HTTP_400_BAD_REQUEST, exctype='badNonce', detail='old nonce is worng', new_nonce=new_nonce) + raise ACMEException(status_code=status.HTTP_400_BAD_REQUEST, exctype='badNonce', detail='old nonce is wrong', new_nonce=new_nonce) return new_nonce diff --git a/tests/pytest/Dockerfile b/tests/pytest/Dockerfile index 8d885dc..fe17b80 100644 --- a/tests/pytest/Dockerfile +++ b/tests/pytest/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.12.7-alpine RUN apk add openssl -RUN pip install pytest +RUN pip install pytest coverage WORKDIR /runner @@ -16,4 +16,4 @@ COPY app ./app/ COPY tests/pytest ./tests/pytest/ -CMD [ "pytest", "." ] +CMD coverage run --branch -m pytest && coverage html --omit="./tests/*" \ No newline at end of file diff --git a/tests/pytest/conftest.py b/tests/pytest/conftest.py index a8d7d86..ca07082 100644 --- a/tests/pytest/conftest.py +++ b/tests/pytest/conftest.py @@ -6,9 +6,12 @@ from pathlib import Path import subprocess +import jwcrypto.jwk +import json -@pytest.fixture -def testclient() -> TestClient: + +@pytest.fixture(scope='session') +def testclient() -> Generator[TestClient, None, None]: os.environ['ca_encryption_key'] = 'M8L6RSYPiHHr6GogXmkQIs7gVai_K5fDDJiNK7zUt0k=' os.environ['external_url'] = 'http://localhost:8000/' @@ -19,7 +22,6 @@ def testclient() -> TestClient: subprocess.call(['openssl', 'genrsa', '-out', ca_dir / 'ca.key', '4096']) subprocess.call(['openssl', 'req', '-new', '-x509', '-nodes', '-days', '3650', '-subj', '/C=DE/O=Demo', '-key', ca_dir / 'ca.key', '-out', ca_dir / 'ca.pem']) - async def noop(): pass @@ -31,3 +33,38 @@ async def noop(): with TestClient(main.app) as tc: yield tc + + +@pytest.fixture(scope='session') +def directory(testclient: TestClient) -> dict[str, str]: + return testclient.get('/acme/directory').json() + + +@pytest.fixture(scope='session') +def session_jwk() -> jwcrypto.jwk.JWK: + # We only need one jwk per session + jwk_key = jwcrypto.jwk.JWK.generate(kty='EC', crv='P-256') + + return jwk_key + + +@pytest.fixture +def signed_request(testclient: TestClient, session_jwk: jwcrypto.jwk.JWK, directory): + class SignedRequest: + @property + def nonce(self): + return testclient.head(directory['newNonce']).headers['Replay-Nonce'] + + def __call__(self, url: str, nonce: str, payload: dict | str, account_url: str | None = None): + jws = jwcrypto.jws.JWS('' if payload == '' else json.dumps(payload)) + protected = {'alg': 'ES256', 'nonce': nonce, 'url': url} + if account_url is None: + protected['jwk'] = session_jwk.export_public(as_dict=True) + else: + protected['kid'] = account_url + + jws.add_signature(session_jwk, protected=protected) + + return testclient.post(url, content=jws.serialize(), headers={'Content-Type': 'application/jose+json'}) + + return SignedRequest() diff --git a/tests/pytest/test_acme_account.py b/tests/pytest/test_acme_account.py new file mode 100644 index 0000000..a2e137c --- /dev/null +++ b/tests/pytest/test_acme_account.py @@ -0,0 +1,15 @@ +_mail_address = 'mailto:dummy@example.com' + + +def test_should_return_error_for_non_existing_accounts(signed_request, directory): + response = signed_request(directory['newAccount'], signed_request.nonce, {'onlyReturnExisting': True}) + + assert response.status_code == 400 + assert response.headers['Content-Type'] == 'application/problem+json' + assert response.json()['type'] == 'urn:ietf:params:acme:error:accountDoesNotExist' + + +def test_should_not_reflect_unknown_fields(signed_request, directory): + response = signed_request(directory['newAccount'], signed_request.nonce, {'contact': [_mail_address], 'unknown': 'dummy'}) + assert response.status_code == 201 + assert 'unknown' not in response.json() diff --git a/tests/pytest/test_acme_directory.py b/tests/pytest/test_acme_directory.py index bb60af7..c57eadc 100644 --- a/tests/pytest/test_acme_directory.py +++ b/tests/pytest/test_acme_directory.py @@ -4,11 +4,28 @@ def test_show_directory(testclient: TestClient): response = testclient.get('/acme/directory') assert response.status_code == 200 + assert response.headers['Content-Type'] == 'application/json' assert response.json() == { - 'newNonce': 'http://localhost:8000/acme/new-nonce', - 'newAccount': 'http://localhost:8000/acme/new-account', - 'newOrder': 'http://localhost:8000/acme/new-order', - 'revokeCert': 'http://localhost:8000/acme/revoke-cert', - 'keyChange': 'http://localhost:8000/acme/key-change', - 'meta': {'website': 'http://localhost:8000/'} + 'newNonce': 'http://localhost:8000/acme/new-nonce', + 'newAccount': 'http://localhost:8000/acme/new-account', + 'newOrder': 'http://localhost:8000/acme/new-order', + 'revokeCert': 'http://localhost:8000/acme/revoke-cert', + 'keyChange': 'http://localhost:8000/acme/key-change', + 'meta': {'website': 'http://localhost:8000/'}, + } + + +def test_directory_shows_terms(testclient: TestClient, monkeypatch): + import config + + monkeypatch.setattr(config.settings.acme, 'terms_of_service_url', 'https://example.com/terms.html') + response = testclient.get('/acme/directory') + assert response.status_code == 200 + assert response.json() == { + 'newNonce': 'http://localhost:8000/acme/new-nonce', + 'newAccount': 'http://localhost:8000/acme/new-account', + 'newOrder': 'http://localhost:8000/acme/new-order', + 'revokeCert': 'http://localhost:8000/acme/revoke-cert', + 'keyChange': 'http://localhost:8000/acme/key-change', + 'meta': {'termsOfService': 'https://example.com/terms.html', 'website': 'http://localhost:8000/'}, } diff --git a/tests/pytest/test_acme_nonce.py b/tests/pytest/test_acme_nonce.py index 2dfe845..c73c444 100644 --- a/tests/pytest/test_acme_nonce.py +++ b/tests/pytest/test_acme_nonce.py @@ -1,10 +1,25 @@ from .conftest import TestClient -def test_generate_nonce(testclient: TestClient): - response = testclient.get('/acme/new-nonce') +import re +import jwcrypto + + +def test_generate_nonce(testclient: TestClient, directory): + response = testclient.get(directory['newNonce']) assert response.status_code == 204 nonce = response.headers['Replay-Nonce'] assert len(nonce) == 43 - response2 = testclient.head('/acme/new-nonce') + assert re.match('^[A-Za-z0-9_-]+$', nonce) + assert len(jwcrypto.common.base64url_decode(nonce)) >= 128 // 8, 'minimum 128bit entropy' + response2 = testclient.head(directory['newNonce']) nonce2 = response2.headers['Replay-Nonce'] assert nonce != nonce2 + + +def test_should_fail_on_bad_nonce(signed_request, directory): + response = signed_request(directory['newAccount'], 'not-a-correct-nonce', {'contact': ['mailto:dummy@example.com']}) + + assert response.status_code == 400 + assert response.headers['Content-Type'] == 'application/problem+json' + assert response.json()['type'] == 'urn:ietf:params:acme:error:badNonce' + assert response.json()['detail'] == 'old nonce is wrong'