Skip to content

Commit

Permalink
test: add tests #138
Browse files Browse the repository at this point in the history
  • Loading branch information
knrdl committed Dec 2, 2024
1 parent a4937a7 commit 500aeda
Show file tree
Hide file tree
Showing 8 changed files with 106 additions and 15 deletions.
4 changes: 4 additions & 0 deletions app/acme/directory/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@

@api.get('/directory')
async def get_directory():
"""
See RFC 8555 7.1.1 "Directory" <https://www.rfc-editor.org/rfc/rfc8555#section-7.1.1>
"""

meta = {'website': settings.external_url}
if settings.acme.terms_of_service_url:
meta['termsOfService'] = settings.acme.terms_of_service_url
Expand Down
3 changes: 3 additions & 0 deletions app/acme/nonce/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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" <https://www.rfc-editor.org/rfc/rfc8555#section-7.2>
"""
response.headers['Replay-Nonce'] = await generate()
response.headers['Cache-Control'] = 'no-store'
response.headers['Link'] = f'<{settings.external_url}acme/directory>;rel="index"'
2 changes: 1 addition & 1 deletion app/acme/nonce/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions tests/pytest/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -16,4 +16,4 @@ COPY app ./app/

COPY tests/pytest ./tests/pytest/

CMD [ "pytest", "." ]
CMD coverage run --branch -m pytest && coverage html --omit="./tests/*"
43 changes: 40 additions & 3 deletions tests/pytest/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/'

Expand All @@ -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

Expand All @@ -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()
15 changes: 15 additions & 0 deletions tests/pytest/test_acme_account.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
_mail_address = 'mailto:[email protected]'


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()
29 changes: 23 additions & 6 deletions tests/pytest/test_acme_directory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/'},
}
21 changes: 18 additions & 3 deletions tests/pytest/test_acme_nonce.py
Original file line number Diff line number Diff line change
@@ -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:[email protected]']})

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'

0 comments on commit 500aeda

Please sign in to comment.