From 8d6614071cc2c1a173aea0bc56e43ff7c11cc349 Mon Sep 17 00:00:00 2001 From: Moul <moul@moul.re> Date: Sun, 30 Aug 2020 11:37:09 +0200 Subject: [PATCH] [feat] #88: Add membership command - #314: dry-run option --- silkaj/cli.py | 2 + silkaj/membership.py | 177 ++++++++++++++++++++++++++ tests/test_membership.py | 263 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 442 insertions(+) create mode 100644 silkaj/membership.py create mode 100644 tests/test_membership.py diff --git a/silkaj/cli.py b/silkaj/cli.py index 60128783..942f9d8c 100644 --- a/silkaj/cli.py +++ b/silkaj/cli.py @@ -34,6 +34,7 @@ from silkaj.commands import ( from silkaj.wot import received_sent_certifications, id_pubkey_correspondence from silkaj.auth import generate_auth_file from silkaj.license import license_command +from silkaj.membership import send_membership from silkaj.blocks import verify_blocks_signatures from silkaj.constants import ( SILKAJ_VERSION, @@ -108,6 +109,7 @@ cli.add_command(transaction_history) cli.add_command(id_pubkey_correspondence) cli.add_command(currency_info) cli.add_command(license_command) +cli.add_command(send_membership) # cli.add_command(network_info) cli.add_command(send_transaction) cli.add_command(verify_blocks_signatures) diff --git a/silkaj/membership.py b/silkaj/membership.py new file mode 100644 index 00000000..c28be501 --- /dev/null +++ b/silkaj/membership.py @@ -0,0 +1,177 @@ +""" +Copyright 2016-2020 Maël Azimi <m.a@moul.re> + +Silkaj is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Silkaj is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with Silkaj. If not, see <https://www.gnu.org/licenses/>. +""" + +import sys +import logging + +import pendulum +import click +from tabulate import tabulate + +from duniterpy.api import bma +from duniterpy.documents import BlockUID, block_uid, Membership + +from silkaj import auth, wot +from silkaj.tools import coroutine +from silkaj.network_tools import ClientInstance +from silkaj.blockchain_tools import BlockchainParams, HeadBlock +from silkaj.license import license_approval +from silkaj.constants import SUCCESS_EXIT_STATUS + + +@click.command( + "membership", + help="Send and sign membership document: \n\ +for first emission and for renewal", +) +@click.option( + "--dry-run", + is_flag=True, + help="By-pass licence, confirmation. \ +Do not send the document, but display it instead", +) +@coroutine +async def send_membership(dry_run): + + # Authentication + key = auth.auth_method() + + # Get the identity information + head_block = await HeadBlock().head_block + membership_timestamp = BlockUID(head_block["number"], head_block["hash"]) + identity = (await wot.choose_identity(key.pubkey))[0] + identity_uid = identity["uid"] + identity_timestamp = block_uid(identity["meta"]["timestamp"]) + + # Display license and ask for confirmation + currency = head_block["currency"] + if not dry_run: + license_approval(currency) + + # Confirmation + client = ClientInstance().client + await display_confirmation_table(identity_uid, key.pubkey, identity_timestamp) + if not dry_run: + if not click.confirm( + "Do you confirm sending a membership document for this identity?" + ): + await client.close() + sys.exit(SUCCESS_EXIT_STATUS) + + membership = generate_membership_document( + currency, + key.pubkey, + membership_timestamp, + identity_uid, + identity_timestamp, + ) + + # Sign document + membership.sign([key]) + + logging.debug(membership.signed_raw()) + + if dry_run: + await client.close() + click.echo(membership.signed_raw()) + sys.exit(SUCCESS_EXIT_STATUS) + + # Send the membership signed raw document to the node + response = await client(bma.blockchain.membership, membership.signed_raw()) + + if response.status == 200: + print("Membership successfully sent") + else: + print("Error while publishing membership: {0}".format(await response.text())) + logging.debug(await response.text()) + await client.close() + + +async def display_confirmation_table(identity_uid, pubkey, identity_timestamp): + """ + Check whether there is pending memberships already in the mempool + Display their expiration date + + Actually, it works sending a membership document even if the time + between two renewals is not awaited as for the certification + """ + + client = ClientInstance().client + + identities_requirements = await client(bma.wot.requirements, pubkey) + for identity_requirements in identities_requirements["identities"]: + if identity_requirements["uid"] == identity_uid: + membership_expires = identity_requirements["membershipExpiresIn"] + pending_expires = identity_requirements["membershipPendingExpiresIn"] + pending_memberships = identity_requirements["pendingMemberships"] + break + + table = list() + if membership_expires: + expires = pendulum.now().add(seconds=membership_expires).diff_for_humans() + table.append(["Current membership will expire", expires]) + + if pending_memberships: + line = [ + "Number of pending membership(s) in the mempool", + len(pending_memberships), + ] + table.append(line) + + expiration = pendulum.now().add(seconds=pending_expires).diff_for_humans() + table.append(["Pending membership documents will expire", expiration]) + + table.append(["Identity uid", identity_uid]) + table.append(["Pubkey", pubkey]) + + table.append(["Identity block UID", identity_timestamp]) + + block = await client(bma.blockchain.block, identity_timestamp.number) + table.append( + ["Identity published", pendulum.from_timestamp(block["time"]).format("LL")] + ) + + params = await BlockchainParams().params + membership_validity = ( + pendulum.now().add(seconds=params["msValidity"]).diff_for_humans() + ) + table.append(["This membership expiration", membership_validity]) + + membership_mempool = ( + pendulum.now().add(seconds=params["msPeriod"]).diff_for_humans() + ) + table.append(["This membership expiration from the mempool", membership_mempool]) + + click.echo(tabulate(table, tablefmt="fancy_grid")) + + +def generate_membership_document( + currency, + pubkey, + membership_timestamp, + identity_uid, + identity_timestamp, +): + return Membership( + version=10, + currency=currency, + issuer=pubkey, + membership_ts=membership_timestamp, + membership_type="IN", + uid=identity_uid, + identity_ts=identity_timestamp, + ) diff --git a/tests/test_membership.py b/tests/test_membership.py new file mode 100644 index 00000000..046d401a --- /dev/null +++ b/tests/test_membership.py @@ -0,0 +1,263 @@ +""" +Copyright 2016-2020 Maël Azimi <m.a@moul.re> + +Silkaj is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Silkaj is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with Silkaj. If not, see <https://www.gnu.org/licenses/>. +""" + +import sys +from unittest.mock import Mock +import pytest +from click.testing import CliRunner + +from tabulate import tabulate +import pendulum + +from duniterpy.documents import Membership, block_uid +from duniterpy.api import bma +from duniterpy.key import SigningKey + +import patched +from silkaj import auth, wot +from silkaj.cli import cli +from silkaj.network_tools import ClientInstance +from silkaj import membership +from silkaj.blockchain_tools import BlockchainParams, HeadBlock +from silkaj.constants import ( + SUCCESS_EXIT_STATUS, + FAILURE_EXIT_STATUS, +) + +# AsyncMock available from Python 3.8. asynctest is used for Py < 3.8 +if sys.version_info[1] > 7: + from unittest.mock import AsyncMock +else: + from asynctest.mock import CoroutineMock as AsyncMock + + +# To be moved/merged into tests/patched.py or tests/patched/<module_name>.py + +currency = "g1" +pubkey = "EA7Dsw39ShZg4SpURsrgMaMqrweJPUFPYHwZA8e92e3D" +identity_timestamp = block_uid( + "0-E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855" +) +identity_uid = "toto" +membership_timestamp = block_uid( + "48000-0000010D30B1284D34123E036B7BE0A449AE9F2B928A77D7D20E3BDEAC7EE14C" +) + + +def patched_auth_method(): + return SigningKey.from_credentials(identity_uid, identity_uid) + + +async def patched_choose_identity(pubkey): + return ( + {"uid": identity_uid, "meta": {"timestamp": identity_timestamp}}, + pubkey, + None, + ) + + +async def patched_params(self): + return { + "msValidity": 31557600, + "msPeriod": 5259600, + } + + +async def patched_block(self, number): + return { + "number": 48000, + "time": 1592243760, + "currency": currency, + "hash": "0000A51FF952B76AAA594A46CA0C8156A56988D2B2B57BE18ECB4F3CFC25CEC2", + } + + +async def patched_wot_requirements_one_pending(pubkey, identity_uid): + return { + "identities": [ + { + "uid": "toto", + "pendingMemberships": [ + { + "membership": "IN", + "issuer": "5B8iMAzq1dNmFe3ZxFTBQkqhq4fsztg1gZvxHXCk1XYH", + "number": 613206, + "blockNumber": 613206, + "userid": "moul-test", + "expires_on": 1598624404, + "type": "IN", + } + ], + "membershipPendingExpiresIn": 6311520, + "membershipExpiresIn": 2603791, + }, + ], + } + + +async def patched_wot_requirements_no_pending(pubkey, identity_uid): + return { + "identities": [ + { + "uid": "toto", + "pendingMemberships": [], + "membershipPendingExpiresIn": 0, + "membershipExpiresIn": 3724115, + } + ] + } + + +@pytest.mark.parametrize( + "dry_run, confirmation, exit_code", + [ + (True, True, SUCCESS_EXIT_STATUS), + (False, False, SUCCESS_EXIT_STATUS), + (False, True, FAILURE_EXIT_STATUS), + ], +) +def test_membership_cmd(dry_run, confirmation, exit_code, monkeypatch): + # Monkeypatch and Mock + monkeypatch.setattr(auth, "auth_method", patched_auth_method) + monkeypatch.setattr(HeadBlock, "get_head", patched.head_block) + monkeypatch.setattr(wot, "choose_identity", patched_choose_identity) + + patched_display_confirmation_table = AsyncMock() + monkeypatch.setattr( + membership, + "display_confirmation_table", + patched_display_confirmation_table, + ) + if not dry_run: + patched_generate_membership_document = Mock() + monkeypatch.setattr( + membership, + "generate_membership_document", + patched_generate_membership_document, + ) + + # Run membership command + command = ["membership"] + if dry_run: + command += ["--dry-run"] + confirmations = "No\nYes\nYes" if confirmation else "No\nYes\nNo" + result = CliRunner().invoke(cli, args=command, input=confirmations) + + # Assert functions are called + patched_display_confirmation_table.assert_awaited_once_with( + identity_uid, + pubkey, + identity_timestamp, + ) + if not dry_run and confirmation: + patched_generate_membership_document.assert_called_with( + currency, + pubkey, + membership_timestamp, + identity_uid, + identity_timestamp, + ) + if dry_run: + assert "Type: Membership" in result.output + + assert result.exit_code == exit_code + + +@pytest.mark.parametrize( + "patched_wot_requirements", + [patched_wot_requirements_no_pending, patched_wot_requirements_one_pending], +) +@pytest.mark.asyncio +async def test_display_confirmation_table( + patched_wot_requirements, monkeypatch, capsys +): + monkeypatch.setattr(bma.wot, "requirements", patched_wot_requirements) + monkeypatch.setattr(bma.blockchain, "parameters", patched_params) + monkeypatch.setattr(bma.blockchain, "block", patched_block) + + client = ClientInstance().client + identities_requirements = await client(bma.wot.requirements, pubkey) + for identity_requirements in identities_requirements["identities"]: + if identity_requirements["uid"] == identity_uid: + membership_expires = identity_requirements["membershipExpiresIn"] + pending_expires = identity_requirements["membershipPendingExpiresIn"] + pending_memberships = identity_requirements["pendingMemberships"] + break + + table = list() + if membership_expires: + expires = pendulum.now().add(seconds=membership_expires).diff_for_humans() + table.append(["Current membership will expire", expires]) + + if pending_memberships: + line = [ + "Number of pending membership(s) in the mempool", + len(pending_memberships), + ] + table.append(line) + expiration = pendulum.now().add(seconds=pending_expires).diff_for_humans() + table.append(["Pending membership documents will expire", expiration]) + + table.append(["Identity uid", identity_uid]) + table.append(["Pubkey", pubkey]) + + table.append(["Identity block UID", identity_timestamp]) + + block = await client(bma.blockchain.block, identity_timestamp.number) + table.append( + ["Identity published", pendulum.from_timestamp(block["time"]).format("LL")], + ) + + params = await BlockchainParams().params + membership_validity = ( + pendulum.now().add(seconds=params["msValidity"]).diff_for_humans() + ) + table.append(["This membership expiration", membership_validity]) + + membership_mempool = ( + pendulum.now().add(seconds=params["msPeriod"]).diff_for_humans() + ) + table.append(["This membership expiration from the mempool", membership_mempool]) + + expected = tabulate(table, tablefmt="fancy_grid") + "\n" + + await membership.display_confirmation_table( + identity_uid, pubkey, identity_timestamp + ) + captured = capsys.readouterr() + assert expected == captured.out + + +def test_generate_membership_document(): + generated_membership = membership.generate_membership_document( + currency, + pubkey, + membership_timestamp, + identity_uid, + identity_timestamp, + ) + expected = Membership( + version=10, + currency=currency, + issuer=pubkey, + membership_ts=membership_timestamp, + membership_type="IN", + uid=identity_uid, + identity_ts=identity_timestamp, + ) + # Direct equality check can be done without raw() once Membership.__eq__() is implemented + assert expected.raw() == generated_membership.raw() -- GitLab