diff --git a/docs/source/interface.rst b/docs/source/interface.rst index e790bd827c6d3e92438980cb4158d081d54a3fd5..2c5e990616b1f30b4dd88e7ba3f05540fc8ac5fe 100644 --- a/docs/source/interface.rst +++ b/docs/source/interface.rst @@ -117,4 +117,14 @@ This endpoint accepts no request body. The response is **OK** with ``application/json`` content-type response body like:: - {"total": <integer>, "unblinded-tokens": [<unblinded token string>, ...]} + { "total": <integer> + , "unblinded-tokens": [<unblinded token string>, ...] + , "lease-maintenance-spending": <spending object> + } + +The ``<spending object>`` may be ``null`` if the lease maintenance process has never run. +If it has run, +``<spending object>`` has two properties: + + * ``when``: associated with an ISO8601 datetime string giving the approximate time the process ran + * ``count``: associated with a number giving the number of passes which would need to be spent to renew leases on all stored objects seen during the lease maintenance activity diff --git a/src/_zkapauthorizer/resource.py b/src/_zkapauthorizer/resource.py index fb7e61aab584103c48ffee628e8835d28b73d9bb..961aea71b7f268e0c23924ded4504795ea136cca 100644 --- a/src/_zkapauthorizer/resource.py +++ b/src/_zkapauthorizer/resource.py @@ -154,8 +154,18 @@ class _UnblindedTokenCollection(Resource): in unblinded_tokens if token > position ), limit)), + u"lease-maintenance-spending": self._lease_maintenance_activity(), }) + def _lease_maintenance_activity(self): + activity = self._store.get_latest_lease_maintenance_activity() + if activity is None: + return activity + return { + u"when": activity.finished.isoformat(), + u"count": activity.passes_required, + } + class _VoucherCollection(Resource): diff --git a/src/_zkapauthorizer/tests/test_client_resource.py b/src/_zkapauthorizer/tests/test_client_resource.py index 653574423654bd9c5d0cb73da3c37f4b01406d4d..aef17914a1fa31a84a0b543581ef35d9ab3d4223 100644 --- a/src/_zkapauthorizer/tests/test_client_resource.py +++ b/src/_zkapauthorizer/tests/test_client_resource.py @@ -47,6 +47,7 @@ from testtools import ( from testtools.matchers import ( MatchesStructure, MatchesAll, + MatchesAny, MatchesPredicate, AllMatch, HasLength, @@ -56,6 +57,7 @@ from testtools.matchers import ( Equals, Always, GreaterThan, + Is, ) from testtools.twistedsupport import ( CaptureTwistedLogs, @@ -65,6 +67,10 @@ from testtools.content import ( text_content, ) +from aniso8601 import ( + parse_datetime, +) + from fixtures import ( TempDir, ) @@ -124,6 +130,11 @@ from ..resource import ( from_configuration, ) +from ..storage_common import ( + BYTES_PER_PASS, + required_passes, +) + from .strategies import ( tahoe_configs, client_unpaidredeemer_configurations, @@ -268,9 +279,10 @@ class UnblindedTokenTests(TestCase): @given(tahoe_configs(), vouchers(), integers(min_value=0, max_value=100)) def test_get(self, get_config, voucher, num_tokens): """ - When the unblinded token collection receives a **GET**, the response is the - total number of unblinded tokens in the system and the unblinded tokens - themselves. + When the unblinded token collection receives a **GET**, the response is + the total number of unblinded tokens in the system, the unblinded + tokens themselves, and information about tokens spent on recent lease + maintenance activity. """ tempdir = self.useFixture(TempDir()) config = get_config(tempdir.join(b"tahoe"), b"tub.port") @@ -374,6 +386,7 @@ class UnblindedTokenTests(TestCase): IsInstance(unicode), ), ), + matches_lease_maintenance_spending(), ), ) @@ -432,8 +445,56 @@ class UnblindedTokenTests(TestCase): ), ) + @given( + tahoe_configs(), + lists( + lists( + integers(min_value=0, max_value=2 ** 63 - 1), + min_size=1, + ), + ), + datetimes(), + ) + def test_latest_lease_maintenance_spending(self, get_config, size_observations, now): + """ + The most recently completed record of lease maintenance spending activity + is reported in the response to a **GET** request. + """ + tempdir = self.useFixture(TempDir()) + config = get_config(tempdir.join(b"tahoe"), b"tub.port") + root = root_from_config(config, lambda: now) + + # Put some activity into it. + total = 0 + activity = root.store.start_lease_maintenance() + for sizes in size_observations: + total += required_passes(BYTES_PER_PASS, sizes) + activity.observe(sizes) + activity.finish() + + agent = RequestTraversalAgent(root) + d = agent.request( + b"GET", + b"http://127.0.0.1/unblinded-token", + ) + d.addCallback(readBody) + d.addCallback( + lambda body: loads(body)[u"lease-maintenance-spending"], + ) + self.assertThat( + d, + succeeded(Equals({ + "when": now.isoformat(), + "count": total, + })), + ) + -def succeeded_with_unblinded_tokens_with_matcher(all_token_count, match_unblinded_tokens): +def succeeded_with_unblinded_tokens_with_matcher( + all_token_count, + match_unblinded_tokens, + match_lease_maint_spending, +): """ :return: A matcher which matches a Deferred which fires with a response like the one returned by the **unblinded-tokens** endpoint. @@ -443,6 +504,9 @@ def succeeded_with_unblinded_tokens_with_matcher(all_token_count, match_unblinde :param match_unblinded_tokens: A matcher for the ``unblinded-tokens`` field of the response. + + :param match_lease_maint_spending: A matcher for the + ``lease-maintenance-spending`` field of the response. """ return succeeded( MatchesAll( @@ -453,6 +517,7 @@ def succeeded_with_unblinded_tokens_with_matcher(all_token_count, match_unblinde ContainsDict({ u"total": Equals(all_token_count), u"unblinded-tokens": match_unblinded_tokens, + u"lease-maintenance-spending": match_lease_maint_spending, }), ), ), @@ -475,9 +540,42 @@ def succeeded_with_unblinded_tokens(all_token_count, returned_token_count): MatchesAll( HasLength(returned_token_count), AllMatch(IsInstance(unicode)), - ) + ), + matches_lease_maintenance_spending(), ) +def matches_lease_maintenance_spending(): + """ + :return: A matcher which matches the value of the + *lease-maintenance-spending* key in the ``unblinded-tokens`` endpoint + response. + """ + return MatchesAny( + Is(None), + ContainsDict({ + u"when": matches_iso8601_datetime(), + u"amount": matches_positive_integer(), + }), + ) + +def matches_positive_integer(): + return MatchesAll( + IsInstance(int), + GreaterThan(0), + ) + +def matches_iso8601_datetime(): + """ + :return: A matcher which matches unicode strings which can be parsed as an + ISO8601 datetime string. + """ + return MatchesAll( + IsInstance(unicode), + AfterPreprocessing( + parse_datetime, + lambda d: Always(), + ), + ) class VoucherTests(TestCase): """