CCF/tests/governance_js.py

1394 строки
50 KiB
Python

# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the Apache 2.0 License.
import infra.network
import infra.path
import infra.proc
import infra.net
import infra.e2e_args
import infra.proposal
import infra.member
import suite.test_requirements as reqs
import os
from loguru import logger as LOG
from contextlib import contextmanager
import dataclasses
import tempfile
import uuid
import infra.clients
import json
import ccf.ledger
def action(name, **args):
return {"name": name, "args": args}
def proposal(*actions):
return {"actions": list(actions)}
def merge(*proposals):
return {"actions": sum((prop["actions"] for prop in proposals), [])}
def vote(body):
return {"ballot": f"export function vote (proposal, proposer_id) {{ {body} }}"}
valid_set_recovery_threshold = proposal(
action("set_recovery_threshold", recovery_threshold=5)
)
valid_set_recovery_threshold_twice = merge(
valid_set_recovery_threshold, valid_set_recovery_threshold
)
always_accept_noop = proposal(action("always_accept_noop"))
always_reject_noop = proposal(action("always_reject_noop"))
always_accept_with_one_vote = proposal(action("always_accept_with_one_vote"))
always_reject_with_one_vote = proposal(action("always_reject_with_one_vote"))
always_accept_if_voted_by_operator = proposal(
action("always_accept_if_voted_by_operator")
)
always_accept_if_proposed_by_operator = proposal(
action("always_accept_if_proposed_by_operator")
)
always_accept_with_two_votes = proposal(action("always_accept_with_two_votes"))
always_reject_with_two_votes = proposal(action("always_reject_with_two_votes"))
check_proposal_id_is_set_correctly = proposal(
action("check_proposal_id_is_set_correctly")
)
ballot_yes = vote("return true")
ballot_no = vote("return false")
def unique_always_accept_noop():
return proposal(action("always_accept_noop", uuid=str(uuid.uuid4())))
def set_service_recent_cose_proposals_window_size(proposal_count):
return proposal(
action(
"set_service_recent_cose_proposals_window_size",
proposal_count=proposal_count,
)
)
def choose_node(network):
# Ideally, this would use find_random_node - you should be able to use any
# node for governance.
# However, many of these tests include a pattern of
# POST /proposal
# GET /proposal
# If the former request is redirected, then the latter may fail (essentially
# reading stale state, assuming session consistency that doesn't exist).
# return network.find_random_node()
# Instead we ensure that all requests go to the primary
primary, _ = network.find_primary()
return primary
@reqs.description("Test COSE msg type validation")
def test_cose_msg_type_validation(network, args):
node = choose_node(network)
with node.api_versioned_client(
None, None, "member0", api_version=args.gov_api_version
) as c:
def check_msg_type(verb, path, name, auth_policy):
r = c.call(
path,
b"{ not valid json",
http_verb=verb,
cose_header_parameters_override={"ccf.gov.msg.type": "incorrect"},
)
assert r.status_code == 401
expected_error = {
"auth_policy": auth_policy,
"code": "InvalidAuthenticationInfo",
"message": f"Found ccf.gov.msg.type set to incorrect, expected ccf.gov.msg.type to be {name}",
}
assert expected_error in r.body.json()["error"]["details"], r.body.json()[
"error"
]["details"]
proposal = os.urandom(32).hex()
member = os.urandom(32).hex()
member_auth = "member_cose_sign1"
active_member_auth = "active_member_cose_sign1"
to_be_checked = [
("POST", "/gov/members/proposals:create", "proposal", active_member_auth),
(
"POST",
f"/gov/members/proposals/{proposal}:withdraw",
"withdrawal",
active_member_auth,
),
(
"POST",
f"/gov/members/proposals/{proposal}/ballots/{member}:submit",
"ballot",
active_member_auth,
),
("POST", f"/gov/members/state-digests/{member}:ack", "ack", member_auth),
(
"POST",
f"/gov/members/state-digests/{member}:update",
"state_digest",
member_auth,
),
]
for verb, path, name, auth_policy in to_be_checked:
check_msg_type(verb, path, name, auth_policy)
@reqs.description("Test proposal validation")
def test_proposal_validation(network, args):
node = choose_node(network)
def assert_invalid_proposal(proposal_body):
try:
member.propose(node, proposal_body)
except infra.proposal.ProposalNotCreated as e:
r = e.response
assert (
r.status_code == 400
and r.body.json()["error"]["code"] == "ProposalFailedToValidate"
), r.body.text()
def assert_malformed_proposal(proposal_body):
try:
member.propose(node, proposal_body)
except infra.proposal.ProposalNotCreated as e:
r = e.response
assert (
r.status_code == 500
and r.body.json()["error"]["code"] == "InternalError"
and r.body.json()["error"]["message"].startswith(
"Failed to execute validation: SyntaxError:"
)
), r.body.text()
member = network.consortium.get_any_active_member()
# Non-JSON body
assert_malformed_proposal(b"{ not valid json")
# Incorrect arg type
assert_invalid_proposal(proposal(action("valid_pem", pem="That's not a PEM")))
# Successfully validated
with open(
os.path.join(network.common_dir, "service_cert.pem"), "r", encoding="utf-8"
) as cert:
valid_pem = cert.read()
member.propose(node, proposal(action("valid_pem", pem=valid_pem)))
# Arg missing
assert_invalid_proposal(proposal(action("remove_user")))
# Not a string
assert_invalid_proposal(proposal(action("remove_user", user_id=42)))
# Too short
assert_invalid_proposal(proposal(action("remove_user", user_id="deadbeef")))
# Too long
assert_invalid_proposal(
proposal(
action(
"remove_user",
user_id="0deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
)
)
)
# Not hex
assert_invalid_proposal(
proposal(
action(
"remove_user",
user_id="totboeuftotboeuftotboeuftotboeuftotboeuftotboeuftotboeuftotboeuf",
)
)
)
# Just right
# NB: It validates (structurally correct type), but does nothing because this user doesn't exist
member.propose(
node,
proposal(
action(
"remove_user",
user_id="deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
)
),
)
return network
@reqs.description("Test proposal storage")
def test_proposal_storage(network, args):
node = choose_node(network)
plausible = os.urandom(32).hex()
with node.api_versioned_client(
None, None, "member0", api_version=args.gov_api_version
) as c:
r = c.get(f"/gov/members/proposals/{plausible}")
assert r.status_code == 404, r.body.text()
r = c.get(f"/gov/members/proposals/{plausible}/actions")
assert r.status_code == 404, r.body.text()
for prop in (valid_set_recovery_threshold, valid_set_recovery_threshold_twice):
r = c.post("/gov/members/proposals:create", prop)
assert r.status_code == 200, r.body.text()
proposal_id = r.body.json()["proposalId"]
r = c.get(f"/gov/members/proposals/{proposal_id}")
assert r.status_code == 200, r.body.text()
proposer_id = network.consortium.get_member_by_local_id(
"member0"
).service_id
expected = {
"proposerId": proposer_id,
"proposalState": "Open",
"proposalId": proposal_id,
"ballotCount": 0,
}
assert r.body.json() == expected, r.body.json()
r = c.get(f"/gov/members/proposals/{proposal_id}/actions")
assert r.status_code == 200, r.body.text()
assert r.body.json() == prop, r.body.json()
return network
@reqs.description("Test proposal withdrawal")
def test_proposal_withdrawal(network, args):
node = choose_node(network)
infra.clients.get_clock().advance()
plausible = os.urandom(32).hex()
with node.api_versioned_client(
None, None, "member0", api_version=args.gov_api_version
) as c:
for prop in (valid_set_recovery_threshold, valid_set_recovery_threshold_twice):
r = c.post(f"/gov/members/proposals/{plausible}:withdraw")
# Idempotent - we don't know if this used to exist
assert r.status_code == 204, r.body.text()
r = c.post("/gov/members/proposals:create", prop)
assert r.status_code == 200, r.body.text()
proposal_id = r.body.json()["proposalId"]
with node.api_versioned_client(
None, None, "member1", api_version=args.gov_api_version
) as oc:
r = oc.post(f"/gov/members/proposals/{proposal_id}:withdraw")
assert r.status_code == 403, r.body.text()
r = c.get(f"/gov/members/proposals/{proposal_id}")
assert r.status_code == 200, r.body.text()
proposer_id = network.consortium.get_member_by_local_id(
"member0"
).service_id
expected = {
"proposerId": proposer_id,
"proposalState": "Open",
"proposalId": proposal_id,
"ballotCount": 0,
}
assert r.body.json() == expected, r.body.json()
r = c.post(f"/gov/members/proposals/{proposal_id}:withdraw")
assert r.status_code == 200, r.body.text()
expected = {
"proposerId": proposer_id,
"proposalState": "Withdrawn",
"proposalId": proposal_id,
"ballotCount": 0,
}
assert r.body.json() == expected, r.body.json()
r = c.post(f"/gov/members/proposals/{proposal_id}:withdraw")
# Idempotent - sure we'll try to withdraw this again
assert r.status_code == 200, r.body.text()
return network
@reqs.description("Test ballot storage and validation")
def test_ballot_storage(network, args):
node = choose_node(network)
infra.clients.get_clock().advance()
with node.api_versioned_client(
None, None, "member0", api_version=args.gov_api_version
) as c:
member_id = network.consortium.get_member_by_local_id("member0").service_id
r = c.post("/gov/members/proposals:create", valid_set_recovery_threshold)
assert r.status_code == 200, r.body.text()
proposal_id = r.body.json()["proposalId"]
r = c.post(
f"/gov/members/proposals/{proposal_id}/ballots/{member_id}:submit", {}
)
assert r.status_code == 400, r.body.text()
ballot = ballot_yes
r = c.post(
f"/gov/members/proposals/{proposal_id}/ballots/{member_id}:submit", ballot
)
assert r.status_code == 200, r.body.text()
r = c.post(
f"/gov/members/proposals/{proposal_id}/ballots/{member_id}:submit", ballot
)
# Idempotence - resubmission is fine
assert r.status_code == 200, r.body.text()
# Changing ballot is not allowed
r = c.post(
f"/gov/members/proposals/{proposal_id}/ballots/{member_id}:submit",
ballot_no,
)
assert r.status_code == 400, r.body.text()
assert r.body.json()["error"]["code"] == "VoteAlreadyExists", r.body.json()
r = c.get(f"/gov/members/proposals/{proposal_id}/ballots/{member_id}")
assert r.status_code == 200, r.body.text()
assert r.headers["content-type"] == "text/javascript"
assert r.body.text() == ballot["ballot"], r.body.text()
with node.api_versioned_client(
None, None, "member1", api_version=args.gov_api_version
) as c:
member_id = network.consortium.get_member_by_local_id("member1").service_id
ballot = ballot_no
r = c.post(
f"/gov/members/proposals/{proposal_id}/ballots/{member_id}:submit", ballot
)
assert r.status_code == 200, r.body.text()
r = c.get(f"/gov/members/proposals/{proposal_id}/ballots/{member_id}")
assert r.status_code == 200, r.body.text()
assert r.headers["content-type"] == "text/javascript"
assert r.body.text() == ballot["ballot"]
return network
@reqs.description("Test pure proposals")
def test_pure_proposals(network, args):
node = choose_node(network)
with node.api_versioned_client(
None, None, "member0", api_version=args.gov_api_version
) as c:
for prop, state in [
(always_accept_noop, "Accepted"),
(always_reject_noop, "Rejected"),
]:
member_id = network.consortium.get_member_by_local_id("member0").service_id
r = c.post("/gov/members/proposals:create", prop)
assert r.status_code == 200, r.body.text()
assert r.body.json()["proposalState"] == state, r.body.json()
proposal_id = r.body.json()["proposalId"]
ballot = ballot_yes
r = c.post(
f"/gov/members/proposals/{proposal_id}/ballots/{member_id}:submit",
ballot,
)
assert r.status_code == 400, r.body.text()
r = c.post(f"/gov/members/proposals/{proposal_id}:withdraw")
assert r.status_code == 400, r.body.text()
return network
@reqs.description("Test proposal replay protection")
def test_proposal_replay_protection(network, args):
node = choose_node(network)
with node.api_versioned_client(
None, None, "member0", api_version=args.gov_api_version
) as c:
# Creating a proposal with too large a created_at always fails
c.set_created_at_override(int("1" + "0" * 10))
r = c.post("/gov/members/proposals:create", always_accept_noop)
assert (
r.status_code == 400
and r.body.json()["error"]["code"] == "InvalidCreatedAt"
), r.body.text()
infra.clients.get_clock().advance()
# Fill window size with proposals
window_size = 100
now = infra.clients.get_clock()
submitted = []
for i in range(window_size):
c.set_created_at_override((now + i).moment())
proposal = unique_always_accept_noop()
r = c.post("/gov/members/proposals:create", proposal)
assert r.status_code == 200, r.body.text()
submitted.append(proposal)
# Re-submitting the last proposal is detected as a replay
last_index = window_size - 1
c.set_created_at_override((now + last_index).moment())
r = c.post("/gov/members/proposals:create", submitted[last_index])
assert (
r.status_code == 400 and r.body.json()["error"]["code"] == "ProposalReplay"
), r.body.text()
# Submitting proposals earlier than, or in the first half of the window is rejected
c.set_created_at_override((now - 1).moment())
r = c.post("/gov/members/proposals:create", always_accept_noop)
assert (
r.status_code == 400
and r.body.json()["error"]["code"] == "ProposalCreatedTooLongAgo"
), r.body.text()
c.set_created_at_override((now + (window_size // 2 - 1)).moment())
r = c.post("/gov/members/proposals:create", always_accept_noop)
assert (
r.status_code == 400
and r.body.json()["error"]["code"] == "ProposalCreatedTooLongAgo"
), r.body.text()
# Submitting a unique proposal just past the median of the window does work
c.set_created_at_override((now + (window_size // 2)).moment())
r = c.post("/gov/members/proposals:create", unique_always_accept_noop())
assert r.status_code == 200, r.body.text()
r = c.post(
"/gov/members/proposals:create",
set_service_recent_cose_proposals_window_size(1),
)
assert r.status_code == 200, r.body.text()
# Submitting a new unique proposal works
c.set_created_at_override((now + window_size).moment())
r = c.post("/gov/members/proposals:create", unique_always_accept_noop())
assert r.status_code == 200, r.body.text()
# Submitting a unique proposal just prior to that no longer does
c.set_created_at_override((now + window_size - 2).moment())
r = c.post("/gov/members/proposals:create", unique_always_accept_noop())
assert (
r.status_code == 400
and r.body.json()["error"]["code"] == "ProposalCreatedTooLongAgo"
), r.body.text()
return network
@reqs.description("Test open proposals")
def test_all_open_proposals(network, args):
node = choose_node(network)
with node.api_versioned_client(
None, None, "member0", api_version=args.gov_api_version
) as c:
r = c.post("/gov/members/proposals:create", always_accept_noop)
assert r.status_code == 200, r.body.text()
first = r.body.json()
assert first["proposalState"] == "Accepted", r.body.json()
r = c.get("/gov/members/proposals")
assert r.status_code == 200, r.body.text()
proposals = r.body.json()["value"]
assert len(proposals) == 1, proposals
# Response at passing time might contain more detail. This later summary is a subset of the earlier object
assert proposals[0].items() <= first.items(), proposals
r = c.post("/gov/members/proposals:create", always_accept_with_one_vote)
assert r.status_code == 200, r.body.text()
second = r.body.json()
assert second["proposalState"] == "Open", second
r = c.get("/gov/members/proposals")
assert r.status_code == 200, r.body.text()
proposals = r.body.json()["value"]
assert len(proposals) == 2, proposals
for proposal in proposals:
if proposal["proposalId"] == first["proposalId"]:
assert proposal.items() <= first.items(), proposal
elif proposal["proposalId"] == second["proposalId"]:
assert proposal.items() <= second.items(), proposal
else:
assert False, proposal
return network
def opposite(js_bool):
if js_bool == "true":
return "false"
elif js_bool == "false":
return "true"
else:
raise ValueError(f"{js_bool} is not a JavaScript boolean")
@reqs.description("Test vote proposals")
def test_proposals_with_votes(network, args):
node = choose_node(network)
with node.api_versioned_client(
None, None, "member0", api_version=args.gov_api_version
) as c:
member_id = network.consortium.get_member_by_local_id("member0").service_id
for prop, state, direction in [
(always_accept_with_one_vote, "Accepted", "true"),
(always_reject_with_one_vote, "Rejected", "false"),
]:
r = c.post("/gov/members/proposals:create", prop)
assert r.status_code == 200, r.body.text()
assert r.body.json()["proposalState"] == "Open", r.body.json()
proposal_id = r.body.json()["proposalId"]
ballot = vote(f"return {direction}")
r = c.post(
f"/gov/members/proposals/{proposal_id}/ballots/{member_id}:submit",
ballot,
)
assert r.status_code == 200, r.body.text()
assert r.body.json()["proposalState"] == state, r.body.json()
infra.clients.get_clock().advance()
r = c.post("/gov/members/proposals:create", prop)
assert r.status_code == 200, r.body.text()
assert r.body.json()["proposalState"] == "Open", r.body.json()
proposal_id = r.body.json()["proposalId"]
ballot = vote(
f'if (proposer_id == "{member_id}") {{ return {direction} }} else {{ return {opposite(direction) } }}'
)
r = c.post(
f"/gov/members/proposals/{proposal_id}/ballots/{member_id}:submit",
ballot,
)
assert r.status_code == 200, r.body.text()
assert r.body.json()["proposalState"] == state, r.body.json()
for prop, state, ballot in [
(always_accept_with_two_votes, "Accepted", ballot_yes),
(always_reject_with_two_votes, "Rejected", ballot_no),
]:
r = c.post("/gov/members/proposals:create", prop)
assert r.status_code == 200, r.body.text()
assert r.body.json()["proposalState"] == "Open", r.body.json()
proposal_id = r.body.json()["proposalId"]
r = c.post(
f"/gov/members/proposals/{proposal_id}/ballots/{member_id}:submit",
ballot,
)
assert r.status_code == 200, r.body.text()
assert r.body.json()["proposalState"] == "Open", r.body.json()
with node.api_versioned_client(
None, None, "member1", api_version=args.gov_api_version
) as oc:
other_member_id = network.consortium.get_member_by_local_id(
"member1"
).service_id
r = oc.post(
f"/gov/members/proposals/{proposal_id}/ballots/{other_member_id}:submit",
ballot,
)
assert r.status_code == 200, r.body.text()
assert r.body.json()["proposalState"] == state, r.body.json()
return network
@reqs.description("Test proposal id is set correctly in resolve()")
def test_check_proposal_id_is_set_correctly(network, args):
node = choose_node(network)
with node.api_versioned_client(
None, None, "member0", api_version=args.gov_api_version
) as c:
r = c.post("/gov/members/proposals:create", check_proposal_id_is_set_correctly)
assert r.status_code == 200, r.body.text()
assert r.body.json()["proposalState"] == "Accepted", r.body.json()
return network
@reqs.description("Test vote failure reporting")
def test_vote_failure_reporting(network, args):
node = choose_node(network)
error_body = f"Sample error ({uuid.uuid4()})"
with node.api_versioned_client(
None, None, "member0", api_version=args.gov_api_version
) as c:
member_id = network.consortium.get_member_by_local_id("member0").service_id
r = c.post("/gov/members/proposals:create", always_accept_with_one_vote)
assert r.status_code == 200, r.body.text()
assert r.body.json()["proposalState"] == "Open", r.body.json()
proposal_id = r.body.json()["proposalId"]
ballot = vote(f'throw new Error("{error_body}")')
r = c.post(
f"/gov/members/proposals/{proposal_id}/ballots/{member_id}:submit", ballot
)
assert r.status_code == 200, r.body.text()
assert r.body.json()["proposalState"] == "Open", r.body.json()
with node.api_versioned_client(
None, None, "member1", api_version=args.gov_api_version
) as c:
ballot = ballot_yes
member_id = network.consortium.get_member_by_local_id("member1").service_id
r = c.post(
f"/gov/members/proposals/{proposal_id}/ballots/{member_id}:submit", ballot
)
assert r.status_code == 200, r.body.text()
rj = r.body.json()
LOG.warning(rj)
assert rj["proposalState"] == "Accepted", r.body.json()
assert len(rj["voteFailures"]) == 1, rj["voteFailures"]
member_id = network.consortium.get_member_by_local_id("member0").service_id
assert rj["voteFailures"][member_id]["reason"] == f"Error: {error_body}", rj[
"voteFailures"
]
return network
@reqs.description("Test operator proposals and votes")
def test_operator_proposals_and_votes(network, args):
node = choose_node(network)
with node.api_versioned_client(
None, None, "member0", api_version=args.gov_api_version
) as c:
r = c.post("/gov/members/proposals:create", always_accept_if_voted_by_operator)
assert r.status_code == 200, r.body.text()
assert r.body.json()["proposalState"] == "Open", r.body.json()
proposal_id = r.body.json()["proposalId"]
ballot = ballot_yes
member_id = network.consortium.get_member_by_local_id("member0").service_id
r = c.post(
f"/gov/members/proposals/{proposal_id}/ballots/{member_id}:submit", ballot
)
assert r.status_code == 200, r.body.text()
assert r.body.json()["proposalState"] == "Accepted", r.body.json()
r = c.post(
"/gov/members/proposals:create", always_accept_if_proposed_by_operator
)
assert r.status_code == 200, r.body.text()
assert r.body.json()["proposalState"] == "Accepted", r.body.json()
return network
@reqs.description("Test operator provisioner proposals")
def test_operator_provisioner_proposals_and_votes(network, args):
node = choose_node(network)
def propose_and_assert_accepted(signer_id, proposal):
with node.api_versioned_client(
None, None, signer_id, api_version=args.gov_api_version
) as c:
r = c.post("/gov/members/proposals:create", proposal)
assert r.status_code == 200, r.body.text()
assert r.body.json()["proposalState"] == "Accepted", r.body.json()
# Create an operator provisioner
operator_provisioner = network.consortium.generate_and_add_new_member(
remote_node=node,
curve=args.participants_curve,
member_data={"is_operator_provisioner": True},
)
operator_provisioner.ack(node)
# Propose the creation of an operator signed by the operator provisioner
operator = infra.member.Member(
"operator",
args.participants_curve,
network.consortium.common_dir,
network.consortium.share_script,
is_recovery_member=False,
key_generator=network.consortium.key_generator,
authenticate_session=network.consortium.authenticate_session,
gov_api_impl=network.consortium.gov_api_impl,
)
cert_file = os.path.join(node.common_dir, operator.member_info["certificate_file"])
set_operator, _ = network.consortium.make_proposal(
"set_member",
cert=open(
cert_file,
encoding="utf-8",
).read(),
member_data={"is_operator": True},
)
propose_and_assert_accepted(
signer_id=operator_provisioner.local_id,
proposal=set_operator,
)
network.consortium.members.append(operator)
operator.ack(node)
# Propose the removal of the operator signed by the operator provisioner
remove_operator, _ = network.consortium.make_proposal(
"remove_member",
member_id=operator.service_id,
)
propose_and_assert_accepted(
signer_id=operator_provisioner.local_id,
proposal=remove_operator,
)
network.consortium.members.remove(operator)
operator.set_retired()
# Create a proposal that the operator provisioner isn't allowed to approve.
illegal_proposal, _ = network.consortium.make_proposal(
"set_member_data",
member_id=network.consortium.get_member_by_local_id("member0").service_id,
member_data={},
)
with node.api_versioned_client(
None, None, "member0", api_version=args.gov_api_version
) as c:
r = c.post("/gov/members/proposals:create", illegal_proposal)
assert r.status_code == 200, r.body.text()
# Unlike earlier proposals, this is _not_ immediately approved
assert r.body.json()["proposalState"] == "Open", r.body.json()
network.consortium.members.remove(operator_provisioner)
operator_provisioner.set_retired()
@reqs.description("Test actions")
def test_actions(network, args):
node = choose_node(network)
# Rekey ledger
network.consortium.trigger_ledger_rekey(node)
# Add new user twice (with and without user data)
new_user_local_id = "js_user"
new_user = network.create_user(new_user_local_id, args.participants_curve)
LOG.info(f"Adding new user {new_user.service_id}")
user_data = None
network.consortium.add_user(node, new_user.local_id, user_data)
user_data = {"foo": "bar"}
network.consortium.add_user(node, new_user.local_id, user_data)
with node.client(new_user.local_id) as c:
r = c.post("/app/log/private", {"id": 0, "msg": "JS"})
assert r.status_code == 200, r.body.text()
# Set user data
network.consortium.set_user_data(
node, new_user.service_id, user_data={"user": "data"}
)
network.consortium.set_user_data(node, new_user.service_id, user_data=None)
# Remove user
network.consortium.remove_user(node, new_user.service_id)
with node.client(new_user.local_id) as c:
r = c.get("/app/log/private")
assert r.status_code == 401, r.body.text()
# Set member data
network.consortium.set_member_data(
node,
network.consortium.get_member_by_local_id("member0").service_id,
member_data={"is_operator": True, "is_admin": True},
)
# Set recovery threshold
try:
network.consortium.set_recovery_threshold(node, recovery_threshold=0)
assert False, "Recovery threshold cannot be set to zero"
except infra.proposal.ProposalNotCreated as e:
assert (
e.response.status_code == 400
and e.response.body.json()["error"]["code"] == "ProposalFailedToValidate"
), e.response.body.text()
try:
network.consortium.set_recovery_threshold(node, recovery_threshold=256)
assert False, "Recovery threshold cannot be set to > 255"
except infra.proposal.ProposalNotCreated as e:
assert (
e.response.status_code == 400
and e.response.body.json()["error"]["code"] == "ProposalFailedToValidate"
), e.response.body.text()
try:
network.consortium.set_recovery_threshold(node, recovery_threshold=None)
assert False, "Recovery threshold value must be passed as proposal argument"
except infra.proposal.ProposalNotCreated as e:
assert (
e.response.status_code == 400
and e.response.body.json()["error"]["code"] == "ProposalFailedToValidate"
), e.response.body.text()
try:
network.consortium.set_recovery_threshold(
node,
recovery_threshold=len(network.consortium.get_active_recovery_members())
+ 1,
)
assert (
False
), "Recovery threshold cannot be greater than the number of active recovery members"
except infra.proposal.ProposalNotAccepted:
pass
network.consortium.set_recovery_threshold(
node, recovery_threshold=network.consortium.recovery_threshold - 1
)
# Refresh recovery shares
network.consortium.trigger_recovery_shares_refresh(node)
# Set member
new_member = network.consortium.generate_and_add_new_member(
node, args.participants_curve
)
member_data = {"foo": "bar"}
new_member = network.consortium.generate_and_add_new_member(
node, args.participants_curve, member_data=member_data
)
# Remove member
network.consortium.remove_member(node, new_member)
network.consortium.remove_member(node, new_member)
return network
@reqs.description("Test resolve and apply failures")
def test_apply(network, args):
node = choose_node(network)
with node.api_versioned_client(
None, None, "member0", api_version=args.gov_api_version
) as c:
member_id = network.consortium.get_member_by_local_id("member0").service_id
r = c.post(
"/gov/members/proposals:create", proposal(action("always_throw_in_apply"))
)
assert r.status_code == 500, r.body.text()
assert r.body.json()["error"]["code"] == "InternalError", r.body.json()
assert (
r.body.json()["error"]["message"].split("\n")[0]
== "Failed to apply(): Error: Error message"
), r.body.json()
r = c.post(
"/gov/members/proposals:create",
proposal(action("always_accept_noop"), action("always_throw_in_apply")),
)
assert r.status_code == 200, r.body().text()
proposal_id = r.body.json()["proposalId"]
r = c.post(
f"/gov/members/proposals/{proposal_id}/ballots/{member_id}:submit",
ballot_yes,
)
assert r.status_code == 200, r.body().text()
with node.api_versioned_client(
None, None, "member1", api_version=args.gov_api_version
) as oc:
member_id = network.consortium.get_member_by_local_id("member1").service_id
r = oc.post(
f"/gov/members/proposals/{proposal_id}/ballots/{member_id}:submit",
ballot_yes,
)
assert r.body.json()["error"]["code"] == "InternalError", r.body.json()
assert (
"Failed to apply():" in r.body.json()["error"]["message"]
), r.body.json()
assert (
"Error: Error message" in r.body.json()["error"]["message"]
), r.body.json()
r = c.post(
"/gov/members/proposals:create",
proposal(action("always_throw_in_resolve")),
)
assert r.status_code == 500, r.body.text()
assert r.body.json()["error"]["code"] == "InternalError", r.body.json()
assert (
"Failed to resolve():" in r.body.json()["error"]["message"]
), r.body.json()
assert (
"Error: Resolve message" in r.body.json()["error"]["message"]
), r.body.json()
return network
@reqs.description("Test set_constitution")
def test_set_constitution(network, args):
node = choose_node(network)
infra.clients.get_clock().advance()
# Create some open proposals
pending_proposals = []
with node.api_versioned_client(
None, None, "member0", api_version=args.gov_api_version
) as c:
r = c.post(
"/gov/members/proposals:create",
valid_set_recovery_threshold,
)
assert r.status_code == 200, r.body.text()
body = r.body.json()
assert body["proposalState"] == "Open", body
pending_proposals.append(body["proposalId"])
r = c.post(
"/gov/members/proposals:create",
always_accept_with_one_vote,
)
assert r.status_code == 200, r.body.text()
body = r.body.json()
assert body["proposalState"] == "Open", body
pending_proposals.append(body["proposalId"])
r = c.get("/gov/service/constitution")
assert r.status_code == 200, r
constitution_before = r.body.text()
# Create a set_constitution proposal, with test proposals removed, and pass it
original_constitution = args.constitution
modified_constitution = [
path for path in original_constitution if "test_actions.js" not in path
]
network.consortium.set_constitution(node, modified_constitution)
with node.api_versioned_client(
None, None, "member0", api_version=args.gov_api_version
) as c:
# Check all other proposals were dropped
for proposal_id in pending_proposals:
r = c.get(f"/gov/members/proposals/{proposal_id}")
assert r.status_code == 200, r.body.text()
assert r.body.json()["proposalState"] == "Dropped", r.body.json()
# Confirm constitution has changed by proposing test actions which are no longer present
r = c.post(
"/gov/members/proposals:create",
always_accept_noop,
)
assert (
r.status_code == 400
and r.body.json()["error"]["code"] == "ProposalFailedToValidate"
), r.body.text()
# Confirm constitution has changed by comparing against previous kv value
r = c.get("/gov/service/constitution")
assert r.status_code == 200, r
constitution_after = r.body.text()
assert constitution_before != constitution_after
r = c.post(
"/gov/members/proposals:create",
always_reject_noop,
)
assert (
r.status_code == 400
and r.body.json()["error"]["code"] == "ProposalFailedToValidate"
), r.body.text()
infra.clients.get_clock().advance()
# Confirm modified constitution can still accept valid proposals
r = c.post(
"/gov/members/proposals:create",
valid_set_recovery_threshold,
)
assert r.status_code == 200, r.body.text()
body = r.body.json()
assert body["proposalState"] == "Open", body
# Restore original constitution
network.consortium.set_constitution(node, original_constitution)
# Confirm original constitution was restored
r = c.post(
"/gov/members/proposals:create",
always_accept_noop,
)
assert r.status_code == 200, r.body.text()
body = r.body.json()
assert body["proposalState"] == "Accepted", body
return network
@contextmanager
def temporary_constitution(network, args, js_constitution_suffix):
primary, _ = network.find_primary()
original_constitution = args.constitution
with tempfile.NamedTemporaryFile("w") as f:
f.write(js_constitution_suffix)
f.flush()
modified_constitution = [path for path in original_constitution] + [f.name]
network.consortium.set_constitution(primary, modified_constitution)
yield
network.consortium.set_constitution(primary, original_constitution)
def make_action_snippet(action_name, validate="", apply=""):
return f"""
actions.set(
"{action_name}",
new Action(
function validate(args) {{ {validate} }},
function apply(args, proposalId) {{ {apply} }}
)
)
"""
@reqs.description("Test read-write restrictions")
def test_read_write_restrictions(network, args):
primary, _ = network.find_primary()
consortium = network.consortium
LOG.info("Test basic constitution replacement")
with temporary_constitution(
network,
args,
make_action_snippet(
"hello_world",
validate="console.log('Validating a hello_world action')",
apply="console.log('Applying a hello_world action')",
),
):
proposal_body, vote = consortium.make_proposal("hello_world")
proposal = consortium.get_any_active_member().propose(primary, proposal_body)
consortium.vote_using_majority(primary, proposal, vote)
@dataclasses.dataclass
class TestSpec:
description: str
table_name: str
readable_in_validate: bool = True
writable_in_validate: bool = True
readable_in_apply: bool = True
writable_in_apply: bool = True
error_contents: list = dataclasses.field(default_factory=list)
tests = [
# Governance tables
TestSpec(
description="Public governance tables cannot be modified during validation",
table_name="public:ccf.gov.my_custom_table",
writable_in_validate=False,
),
TestSpec(
description="Private governance tables cannot even be read",
table_name="ccf.gov.my_custom_table",
readable_in_validate=False,
writable_in_validate=False,
readable_in_apply=False,
writable_in_apply=False,
),
# Internal tables
TestSpec(
description="Public internal tables are read-only",
table_name="public:ccf.internal.my_custom_table",
writable_in_validate=False,
writable_in_apply=False,
),
TestSpec(
description="Private internal tables cannot even be read",
table_name="ccf.internal.my_custom_table",
readable_in_validate=False,
writable_in_validate=False,
readable_in_apply=False,
writable_in_apply=False,
),
# Application tables
TestSpec(
description="Public application tables are read-only",
table_name="public:my.app.my_custom_table",
readable_in_validate=False,
writable_in_validate=False,
readable_in_apply=False,
writable_in_apply=False,
),
TestSpec(
description="Private application tables cannot even be read",
table_name="my.app.my_custom_table",
readable_in_validate=False,
writable_in_validate=False,
readable_in_apply=False,
writable_in_apply=False,
),
]
def make_script(table_name, kind):
return f"""
const table_name = "{table_name}";
var table = ccf.kv[table_name];
if (args.try.includes("read_during_{kind}")) {{ table.get(getSingletonKvKey()); }}
if (args.try.includes("write_during_{kind}")) {{ table.delete(getSingletonKvKey()); }}
"""
action_name = "temp_action"
for test in tests:
LOG.info(test.description)
with temporary_constitution(
network,
args,
make_action_snippet(
action_name,
validate=make_script(test.table_name, "validate"),
apply=make_script(test.table_name, "apply"),
),
):
for should_succeed, proposal_args in (
(test.readable_in_validate, {"try": ["read_during_validate"]}),
(test.writable_in_validate, {"try": ["write_during_validate"]}),
(test.readable_in_apply, {"try": ["read_during_apply"]}),
(test.writable_in_apply, {"try": ["write_during_apply"]}),
):
proposal_body, vote = consortium.make_proposal(
action_name, **proposal_args
)
desc = f"during '{test.description}', doing {proposal_args}, expecting {should_succeed}"
try:
proposal = consortium.get_any_active_member().propose(
primary, proposal_body
)
consortium.vote_using_majority(primary, proposal, vote)
assert should_succeed, f"Proposal was applied unexpectedly ({desc})"
except (
infra.proposal.ProposalNotCreated,
infra.proposal.ProposalNotAccepted,
) as e:
msg = e.response.body.json()["error"]["message"]
assert (
not should_succeed
), f"Proposal failed unexpectedly ({desc}): {msg}"
return network
@reqs.description("Test access to accepted proposal state")
def test_final_proposal_visibility(network, args):
primary, _ = network.find_primary()
consortium = network.consortium
with temporary_constitution(
network,
args,
# An proposal that counts who voted for it.
# Proving that such a thing is _possible_, but in practice we mostly expect that this visibility will only be used for reporting.
make_action_snippet(
"vote_provenance",
apply="""
let proposals = ccf.kv["public:ccf.gov.proposals_info"];
let proposalInfoBuffer = proposals.get(ccf.strToBuf(proposalId));
if (proposalInfoBuffer === undefined) { throw new Error(`Can't find proposal info for ${proposalId}`); }
const proposalInfo = ccf.bufToJsonCompatible(proposalInfoBuffer);
const state = proposalInfo.state;
if (state != "Accepted") { throw new Error(`apply() received proposal in unexpected state ${state}`); }
const finalVotes = proposalInfo.final_votes;
if (finalVotes === undefined) { throw new Error("Don't have finalVotes"); }
let supporters = ccf.kv["public:ccf.gov.testonly.supporter_points"];
for (const [memberId, vote] of Object.entries(finalVotes)) {
const memberIdBuf = ccf.strToBuf(memberId);
if (vote === true) {
if (supporters.has(memberIdBuf)) {
const prev = ccf.bufToJsonCompatible(supporters.get(memberIdBuf));
supporters.set(memberIdBuf, ccf.jsonCompatibleToBuf(prev + 1));
} else {
supporters.set(memberIdBuf, ccf.jsonCompatibleToBuf(1));
}
} else {
// Null points for anyone who voted against this
supporters.set(memberIdBuf, ccf.jsonCompatibleToBuf(0));
}
}
console.log("Current supporter scoreboard is:");
supporters.forEach((v, k) => {
console.log(` Member ${ccf.bufToStr(k)} has ${ccf.bufToJsonCompatible(v)} points`);
});
""",
),
):
members = consortium.get_active_members()
assert len(members) >= 3
booster = members[0]
fairweather = members[1]
turncoat = members[2]
proposal_body, ballot = consortium.make_proposal("vote_provenance")
first = consortium.get_any_active_member().propose(primary, proposal_body)
response = booster.vote(primary, first, ballot)
assert response.status_code == 200
response = turncoat.vote(primary, first, ballot)
assert response.status_code == 200
second = consortium.get_any_active_member().propose(primary, proposal_body)
response = booster.vote(primary, second, ballot)
assert response.status_code == 200
response = fairweather.vote(primary, second, ballot)
assert response.status_code == 200
third = consortium.get_any_active_member().propose(primary, proposal_body)
response = booster.vote(primary, third, ballot)
assert response.status_code == 200
# Votes against! Loses supporter points!
response = turncoat.vote(
primary,
third,
json.dumps(
{
"ballot": "export function vote (rawProposal, proposerId) { return false }"
}
),
)
assert response.status_code == 200
response = fairweather.vote(primary, third, ballot)
assert response.status_code == 200
LOG.info("Confirm that finalVotes is present in submit-ballot response")
body = response.body.json()
assert "finalVotes" in body, body
LOG.info("Confirm that finalVotes is present in get-proposal response")
body = consortium.get_proposal_raw(primary, third.proposal_id)
assert "finalVotes" in body, body
LOG.info("Confirm that expected values were actually written to the KV")
# To avoid creating an extra endpoint in the app, we smuggle a read into a new
# action's apply, reported to the caller via an exception
with temporary_constitution(
network,
args,
make_action_snippet(
"read_supporters",
apply="""
let supporters = ccf.kv["public:ccf.gov.testonly.supporter_points"];
let s = "Current supporter scoreboard is:\\n";
supporters.forEach((v, k) => {
s += ` Member ${ccf.bufToStr(k)} has ${ccf.bufToJsonCompatible(v)} points\\n`;
});
throw new Error(s);
""",
),
):
proposal_body, ballot = consortium.make_proposal("read_supporters")
proposal = consortium.get_any_active_member().propose(primary, proposal_body)
expected_lines = [
f"Member {booster.service_id} has 3 points",
f"Member {fairweather.service_id} has 2 points",
f"Member {turncoat.service_id} has 0 points",
]
thrown = False
try:
consortium.vote_using_majority(primary, proposal, ballot)
except infra.proposal.ProposalNotAccepted as e:
thrown = True
msg = e.response.body.json()["error"]["message"]
for line in expected_lines:
assert line in msg
assert thrown
return network
@reqs.description("Test final proposal description written to KV")
def test_ledger_governance_invariants(network, args):
node = network.nodes[0]
ledger_dirs = node.remote.ledger_paths()
ledger = ccf.ledger.Ledger(ledger_dirs)
LOG.info("Completed proposals contain final_vote for each submitted ballot")
table_name = "public:ccf.gov.proposals_info"
seen_states = set()
for transaction in ledger.transactions():
public_tables = transaction.get_public_domain().get_tables()
if table_name not in public_tables:
continue
for _, raw_proposal in public_tables[table_name].items():
if raw_proposal is None:
# This is a deletion
continue
proposal = json.loads(raw_proposal)
state = proposal["state"]
seen_states.add(state)
if state in ("Open", "Withdrawn", "Dropped"):
# This proposal contains no final_votes
continue
ballots = proposal["ballots"]
final_votes = proposal["final_votes"]
vote_failures = proposal["vote_failures"]
all_submitted = set(ballots.keys())
all_results = set.union(set(final_votes.keys()), set(vote_failures.keys()))
assert all_submitted == all_results, proposal
LOG.info("Confirm that previous tests properly stressed this behaviour")
expected = {
"Accepted",
"Dropped",
# "Failed", # This state produces an error, and is never written to the KV
"Open",
"Rejected",
"Withdrawn",
}
diff = seen_states.symmetric_difference(expected)
assert len(diff) == 0, diff
return network