diff --git a/.github/workflows/docker-image.yml b/.github/workflows/integration-tests.yml similarity index 68% rename from .github/workflows/docker-image.yml rename to .github/workflows/integration-tests.yml index 2ed4ebe6a..79e84c6db 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/integration-tests.yml @@ -1,4 +1,4 @@ -name: Cello CI +name: Integration Tests on: push: @@ -24,7 +24,17 @@ jobs: - name: Build Hyperledger Fabric Node working-directory: src/nodes/hyperledger-fabric - run: docker build -t hyperledger/fabric:2.5.14 . + run: docker build -t hyperledger/fabric:2.5.15 . + + - name: Run newman tests + working-directory: tests/postman + run: docker compose up --abort-on-container-exit + + - name: Stop Hyperledger Fabric Chaincode + run: docker ps -q --filter "name=dev-peer0.org1.foo.com-basic_1.0" | xargs -r docker stop + + - name: Stop Hyperledger Fabric Nodes + run: docker stop orderer0.foo.com peer0.org1.foo.com - name: Stop Hyperledger Fabric Agent run: docker stop cello-hyperledger-fabric-agent diff --git a/.github/workflows/check-code.yml b/.github/workflows/lint-check.yml similarity index 96% rename from .github/workflows/check-code.yml rename to .github/workflows/lint-check.yml index 98e1bf414..ee739efd2 100644 --- a/.github/workflows/check-code.yml +++ b/.github/workflows/lint-check.yml @@ -1,4 +1,4 @@ -name: Code Check CI +name: Lint Check on: push: @@ -12,21 +12,26 @@ jobs: steps: - uses: actions/checkout@v3 + - name: Set up Python uses: actions/setup-python@v4 with: python-version: "3.8" + - name: Set up Nodejs uses: actions/setup-node@v3 with: node-version: "20" + - name: Install Python dependencies run: | python -m pip install --upgrade pip python -m pip install tox tox-gh-actions + - name: Install Nodejs dependencies working-directory: src/dashboard run: | yarn install --frozen-lockfile + - name: Check code run: make check diff --git a/src/agents/hyperledger-fabric/Dockerfile b/src/agents/hyperledger-fabric/Dockerfile index 978023519..7908d6fbd 100644 --- a/src/agents/hyperledger-fabric/Dockerfile +++ b/src/agents/hyperledger-fabric/Dockerfile @@ -1,5 +1,7 @@ FROM python:3.13 +ENV FABRIC_VERSION=2.5.15 + # Install software RUN apt-get update\ && apt-get autoclean\ @@ -18,7 +20,7 @@ RUN ARCH=$(dpkg --print-architecture) && \ amd64|arm64) FABRIC_ARCH="$ARCH" ;; \ *) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;; \ esac && \ - curl -fsSL --retry 5 --retry-delay 3 "https://github.com/hyperledger/fabric/releases/download/v2.5.14/hyperledger-fabric-linux-${FABRIC_ARCH}-2.5.14.tar.gz" | tar xz -C ./cello/ + curl -fsSL --retry 5 --retry-delay 3 "https://github.com/hyperledger/fabric/releases/download/v${FABRIC_VERSION}/hyperledger-fabric-linux-${FABRIC_ARCH}-${FABRIC_VERSION}.tar.gz" | tar xz -C ./cello/ # Install python dependencies RUN pip3 install -r requirements.txt diff --git a/src/agents/hyperledger-fabric/chaincode/serializers.py b/src/agents/hyperledger-fabric/chaincode/serializers.py index f6d449a7d..f7e0f01db 100644 --- a/src/agents/hyperledger-fabric/chaincode/serializers.py +++ b/src/agents/hyperledger-fabric/chaincode/serializers.py @@ -117,7 +117,7 @@ def create(self, validated_data): threading.Thread( target=install_chaincode, - args=(fs.path(filename)), + args=(fs.path(filename),), daemon=True).start() return self diff --git a/src/agents/hyperledger-fabric/chaincode/service.py b/src/agents/hyperledger-fabric/chaincode/service.py index e4afb4432..25a1cf6c2 100644 --- a/src/agents/hyperledger-fabric/chaincode/service.py +++ b/src/agents/hyperledger-fabric/chaincode/service.py @@ -3,14 +3,15 @@ import os import subprocess import tarfile +import docker import yaml from typing import List, Optional, Dict, Any from chaincode.enums import ChaincodeStatus -from hyperledger_fabric.settings import CELLO_HOME, CRYPTO_CONFIG, FABRIC_TOOL +from hyperledger_fabric.settings import CELLO_HOME, CRYPTO_CONFIG, FABRIC_TOOL, FABRIC_VERSION LOG = logging.getLogger(__name__) - +docker_client = docker.DockerClient("unix:///var/run/docker.sock") def get_chaincode_status( package_id: str, @@ -280,6 +281,8 @@ def install_chaincode(file_path: str): ) as f: crypto_config = yaml.safe_load(f) + docker_client.images.pull("hyperledger/fabric-ccenv", tag=FABRIC_VERSION.rsplit(".", 1)[0]) + peer_organization_directory = os.path.join( CELLO_HOME, "peerOrganizations", diff --git a/src/agents/hyperledger-fabric/hyperledger_fabric/settings.py b/src/agents/hyperledger-fabric/hyperledger_fabric/settings.py index 438decd04..60dd08c85 100644 --- a/src/agents/hyperledger-fabric/hyperledger_fabric/settings.py +++ b/src/agents/hyperledger-fabric/hyperledger_fabric/settings.py @@ -131,7 +131,7 @@ CELLO_HOME = os.path.join(BASE_DIR, "cello") CRYPTO_CONFIG = os.path.join(CELLO_HOME, "crypto-config.yaml") FABRIC_TOOL = os.path.join(CELLO_HOME, "bin") -FABRIC_VERSION = "2.5.14" +FABRIC_VERSION = "2.5.15" LOGGING = { "version": 1, diff --git a/src/agents/hyperledger-fabric/node/service.py b/src/agents/hyperledger-fabric/node/service.py index f872bbca2..a8eff7480 100644 --- a/src/agents/hyperledger-fabric/node/service.py +++ b/src/agents/hyperledger-fabric/node/service.py @@ -12,8 +12,6 @@ from node.enums import NodeType LOG = logging.getLogger(__name__) - - docker_client = docker.DockerClient("unix:///var/run/docker.sock") def get_node_status(node_type: str, name: str) -> str: diff --git a/src/api-engine/channel/migrations/0002_channel_invitation.py b/src/api-engine/channel/migrations/0002_channel_invitation.py new file mode 100644 index 000000000..53b7edafe --- /dev/null +++ b/src/api-engine/channel/migrations/0002_channel_invitation.py @@ -0,0 +1,221 @@ +import common.utils +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("channel", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="ChannelInvitation", + fields=[ + ( + "id", + models.UUIDField( + default=common.utils.make_uuid, + help_text="Channel Invitation ID", + primary_key=True, + serialize=False, + ), + ), + ( + "status", + models.CharField( + choices=[ + ("DRAFT", "Draft"), + ("SIGNING", "Signing"), + ("READY", "Ready"), + ("ACCEPTED", "Accepted"), + ("REJECTED", "Rejected"), + ("FAILED", "Failed"), + ("CANCELED", "Canceled"), + ], + default="DRAFT", + help_text="Channel invitation status", + max_length=32, + ), + ), + ( + "artifact", + models.FileField( + blank=True, + help_text="Update Artifact", + null=True, + upload_to="channel_invitations/", + ), + ), + ( + "artifact_hash", + models.CharField( + blank=True, + default="", + help_text="Artifact Hash", + max_length=64, + ), + ), + ( + "required_signatures", + models.PositiveSmallIntegerField( + default=0, + help_text="Required Signatures", + ), + ), + ( + "error_message", + models.TextField( + blank=True, + default="", + help_text="Error Message", + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True), + ), + ( + "channel", + models.ForeignKey( + help_text="Invitation Channel", + on_delete=django.db.models.deletion.CASCADE, + related_name="invitations", + to="channel.channel", + ), + ), + ( + "creator_organization", + models.ForeignKey( + help_text="Creator Organization", + on_delete=django.db.models.deletion.CASCADE, + related_name="created_invitations", + to="organization.organization", + ), + ), + ], + options={ + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="ChannelInvitationSignature", + fields=[ + ( + "id", + models.UUIDField( + default=common.utils.make_uuid, + help_text="Channel Invitation Signature ID", + primary_key=True, + serialize=False, + ), + ), + ( + "artifact_hash", + models.CharField( + help_text="Artifact Hash", + max_length=64, + ), + ), + ( + "signed_at", + models.DateTimeField(auto_now_add=True), + ), + ( + "invitation", + models.ForeignKey( + help_text="Invitation", + on_delete=django.db.models.deletion.CASCADE, + related_name="signatures", + to="channel.channelinvitation", + ), + ), + ( + "organization", + models.ForeignKey( + help_text="Signing Organization", + on_delete=django.db.models.deletion.CASCADE, + related_name="invitation_signatures", + to="organization.organization", + ), + ), + ], + options={ + "ordering": ("signed_at",), + }, + ), + migrations.CreateModel( + name="ChannelInvitationInvitee", + fields=[ + ( + "id", + models.UUIDField( + default=common.utils.make_uuid, + help_text="Channel Invitation Invitee ID", + primary_key=True, + serialize=False, + ), + ), + ( + "status", + models.CharField( + choices=[ + ("PENDING", "Pending"), + ("ACCEPTED", "Accepted"), + ("REJECTED", "Rejected"), + ], + default="PENDING", + help_text="Invitee Status", + max_length=32, + ), + ), + ( + "responded_at", + models.DateTimeField( + blank=True, + null=True, + ), + ), + ( + "invitation", + models.ForeignKey( + help_text="Invitation", + on_delete=django.db.models.deletion.CASCADE, + related_name="invitees", + to="channel.channelinvitation", + ), + ), + ( + "organization", + models.ForeignKey( + help_text="Invited Organization", + on_delete=django.db.models.deletion.CASCADE, + related_name="invitee_invitations", + to="organization.organization", + ), + ), + ], + options={ + "ordering": ("id",), + }, + ), + migrations.AddConstraint( + model_name="channelinvitationsignature", + constraint=models.UniqueConstraint( + fields=("invitation", "organization"), + name="unique_channel_invitation_signature", + ), + ), + migrations.AddConstraint( + model_name="channelinvitationinvitee", + constraint=models.UniqueConstraint( + fields=("invitation", "organization"), + name="unique_channel_invitation_invitee", + ), + ), + ] diff --git a/src/api-engine/channel/models.py b/src/api-engine/channel/models.py index c68b6aaf8..cabbdf18c 100644 --- a/src/api-engine/channel/models.py +++ b/src/api-engine/channel/models.py @@ -1,7 +1,7 @@ from django.db import models +from django.db.models import Q from common.utils import make_uuid -from node.models import Node from organization.models import Organization @@ -26,3 +26,161 @@ class Channel(models.Model): class Meta: ordering = ("-created_at",) + + +class ChannelInvitationQuerySet(models.QuerySet): + def visible_to_organization(self, organization: Organization): + return self.filter( + Q(channel__organizations=organization) + | Q( + status__in=("READY", "ACCEPTED", "REJECTED", "FAILED"), + invitees__organization=organization, + ) + ).distinct() + + +class ChannelInvitation(models.Model): + class Status(models.TextChoices): + DRAFT = "DRAFT", "Draft" + SIGNING = "SIGNING", "Signing" + READY = "READY", "Ready" + ACCEPTED = "ACCEPTED", "Accepted" + REJECTED = "REJECTED", "Rejected" + FAILED = "FAILED", "Failed" + CANCELED = "CANCELED", "Canceled" + + id = models.UUIDField( + primary_key=True, + help_text="Channel Invitation ID", + default=make_uuid, + ) + channel = models.ForeignKey( + Channel, + help_text="Invitation Channel", + related_name="invitations", + on_delete=models.CASCADE, + ) + creator_organization = models.ForeignKey( + Organization, + help_text="Creator Organization", + related_name="created_invitations", + on_delete=models.CASCADE, + ) + status = models.CharField( + help_text="Channel invitation status", + choices=Status.choices, + default=Status.DRAFT, + max_length=32, + ) + artifact = models.FileField( + help_text="Update Artifact", + upload_to="channel_invitations/", + null=True, + blank=True, + ) + artifact_hash = models.CharField( + help_text="Artifact Hash", + max_length=64, + blank=True, + default="", + ) + required_signatures = models.PositiveSmallIntegerField( + help_text="Required Signatures", + default=0, + ) + error_message = models.TextField( + help_text="Error Message", + blank=True, + default="", + ) + created_at = models.DateTimeField( + auto_now_add=True, + ) + updated_at = models.DateTimeField( + auto_now=True, + ) + + objects = ChannelInvitationQuerySet.as_manager() + + class Meta: + ordering = ("-created_at",) + + +class ChannelInvitationInvitee(models.Model): + class Status(models.TextChoices): + PENDING = "PENDING", "Pending" + ACCEPTED = "ACCEPTED", "Accepted" + REJECTED = "REJECTED", "Rejected" + + id = models.UUIDField( + primary_key=True, + help_text="Channel Invitation Invitee ID", + default=make_uuid, + ) + invitation = models.ForeignKey( + ChannelInvitation, + help_text="Invitation", + related_name="invitees", + on_delete=models.CASCADE, + ) + organization = models.ForeignKey( + Organization, + help_text="Invited Organization", + related_name="invitee_invitations", + on_delete=models.CASCADE, + ) + status = models.CharField( + help_text="Invitee Status", + choices=Status.choices, + default=Status.PENDING, + max_length=32, + ) + responded_at = models.DateTimeField( + null=True, + blank=True, + ) + + class Meta: + ordering = ("id",) + constraints = [ + models.UniqueConstraint( + fields=("invitation", "organization"), + name="unique_channel_invitation_invitee", + ) + ] + + +class ChannelInvitationSignature(models.Model): + id = models.UUIDField( + primary_key=True, + help_text="Channel Invitation Signature ID", + default=make_uuid, + ) + invitation = models.ForeignKey( + ChannelInvitation, + help_text="Invitation", + related_name="signatures", + on_delete=models.CASCADE, + ) + organization = models.ForeignKey( + Organization, + help_text="Signing Organization", + related_name="invitation_signatures", + on_delete=models.CASCADE, + ) + artifact_hash = models.CharField( + help_text="Artifact Hash", + max_length=64, + ) + signed_at = models.DateTimeField( + auto_now_add=True, + ) + + class Meta: + ordering = ("signed_at",) + constraints = [ + models.UniqueConstraint( + fields=("invitation", "organization"), + name="unique_channel_invitation_signature", + ) + ] diff --git a/src/api-engine/channel/serializers.py b/src/api-engine/channel/serializers.py index 9d7fd6c5c..79323bab0 100644 --- a/src/api-engine/channel/serializers.py +++ b/src/api-engine/channel/serializers.py @@ -1,11 +1,18 @@ from typing import Dict, Any +from django.db import transaction from rest_framework import serializers -from channel.models import Channel +from channel.models import ( + Channel, + ChannelInvitation, + ChannelInvitationInvitee, + ChannelInvitationSignature, +) from channel.service import create from common.serializers import ListResponseSerializer from node.service import organization_orderer_exists, organization_peer_exists +from organization.models import Organization from organization.serializers import OrganizationID @@ -47,3 +54,139 @@ def create(self, validated_data: Dict[str, Any]) -> ChannelID: return ChannelID(create( self.context["organization"], validated_data["name"])) + + +class ChannelInvitationInviteeResponse(serializers.ModelSerializer): + organization = OrganizationID() + + class Meta: + model = ChannelInvitationInvitee + fields = ( + "id", + "organization", + "status", + "responded_at", + ) + read_only_fields = fields + + +class ChannelInvitationSignatureResponse(serializers.ModelSerializer): + organization = OrganizationID() + + class Meta: + model = ChannelInvitationSignature + fields = ( + "id", + "organization", + "artifact_hash", + "signed_at", + ) + read_only_fields = fields + + +class ChannelInvitationResponse(serializers.ModelSerializer): + channel = ChannelID() + creator_organization = OrganizationID() + invitees = ChannelInvitationInviteeResponse(many=True) + signatures = ChannelInvitationSignatureResponse(many=True) + + class Meta: + model = ChannelInvitation + fields = ( + "id", + "channel", + "creator_organization", + "status", + "artifact_hash", + "required_signatures", + "error_message", + "invitees", + "signatures", + "created_at", + "updated_at", + ) + read_only_fields = fields + + +class ChannelInvitationList(ListResponseSerializer): + data = ChannelInvitationResponse(many=True) + + +class ChannelInvitationCreateBody(serializers.Serializer): + organization_ids = serializers.ListField( + child=serializers.UUIDField(), + allow_empty=False, + ) + required_signatures = serializers.IntegerField( + required=False, + min_value=1, + ) + + def validate_organization_ids(self, value): + if len(set(value)) != len(value): + raise serializers.ValidationError( + "Duplicated organizations are not allowed." + ) + return value + + def validate(self, attrs): + channel = self.context["channel"] + creator = self.context["organization"] + + if not channel.organizations.filter(pk=creator.pk).exists(): + raise serializers.ValidationError( + "Not a channel member." + ) + + org_ids = set(attrs["organization_ids"]) + existing = { + o.id: o.name + for o in Organization.objects.filter(pk__in=org_ids) + } + missing = [str(oid) for oid in org_ids if oid not in existing] + if missing: + raise serializers.ValidationError({ + "organization_ids": [ + f"Organization does not exist: {oid}" for oid in missing + ] + }) + + member_ids = set(channel.organizations.values_list("id", flat=True)) + already_members = [ + existing[oid] for oid in org_ids if oid in member_ids + ] + if already_members: + raise serializers.ValidationError({ + "organization_ids": [ + f"Already a member: {name}" for name in already_members + ] + }) + + member_count = channel.organizations.count() + required = attrs.get("required_signatures", member_count) + if required > member_count: + raise serializers.ValidationError({ + "required_signatures": "Cannot exceed member count." + }) + + attrs["organizations"] = Organization.objects.filter( + pk__in=org_ids + ) + attrs["required_signatures"] = required + return attrs + + def create(self, validated_data): + orgs = validated_data.pop("organizations") + validated_data.pop("organization_ids") + with transaction.atomic(): + invitation = ChannelInvitation.objects.create( + channel=self.context["channel"], + creator_organization=self.context["organization"], + required_signatures=validated_data["required_signatures"], + ) + ChannelInvitationInvitee.objects.bulk_create([ + ChannelInvitationInvitee( + invitation=invitation, organization=o + ) for o in orgs + ]) + return invitation diff --git a/src/api-engine/channel/tests.py b/src/api-engine/channel/tests.py index 7ce503c2d..65e6ecf4f 100644 --- a/src/api-engine/channel/tests.py +++ b/src/api-engine/channel/tests.py @@ -1,3 +1,241 @@ +from django.db import IntegrityError, transaction from django.test import TestCase -# Create your tests here. +from channel.models import ( + Channel, + ChannelInvitation, + ChannelInvitationInvitee, + ChannelInvitationSignature, +) +from channel.serializers import ChannelInvitationCreateBody +from organization.models import Organization + + +class ChannelInvitationTestCase(TestCase): + def setUp(self): + self.member_org = Organization.objects.create( + name="member.example.com", + agent_url="http://member-agent.example.com", + ) + self.second_member_org = Organization.objects.create( + name="second.example.com", + agent_url="http://second-agent.example.com", + ) + self.invited_org = Organization.objects.create( + name="invited.example.com", + agent_url="http://invited-agent.example.com", + ) + self.other_org = Organization.objects.create( + name="other.example.com", + agent_url="http://other-agent.example.com", + ) + self.channel = Channel.objects.create(name="testchannel") + self.channel.organizations.add( + self.member_org, + self.second_member_org, + ) + + def create_invitation(self, status=ChannelInvitation.Status.DRAFT): + invitation = ChannelInvitation.objects.create( + channel=self.channel, + creator_organization=self.member_org, + status=status, + required_signatures=2, + ) + ChannelInvitationInvitee.objects.create( + invitation=invitation, + organization=self.invited_org, + ) + return invitation + + def test_invitation_defaults_to_draft(self): + invitation = ChannelInvitation.objects.create( + channel=self.channel, + creator_organization=self.member_org, + ) + + self.assertEqual(invitation.status, ChannelInvitation.Status.DRAFT) + self.assertEqual(invitation.artifact_hash, "") + self.assertEqual(invitation.required_signatures, 0) + + def test_invitee_is_unique_per_invitation(self): + invitation = self.create_invitation() + + with self.assertRaises(IntegrityError): + with transaction.atomic(): + ChannelInvitationInvitee.objects.create( + invitation=invitation, + organization=self.invited_org, + ) + + def test_signature_is_unique_per_invitation(self): + invitation = self.create_invitation() + ChannelInvitationSignature.objects.create( + invitation=invitation, + organization=self.member_org, + artifact_hash="a" * 64, + ) + + with self.assertRaises(IntegrityError): + with transaction.atomic(): + ChannelInvitationSignature.objects.create( + invitation=invitation, + organization=self.member_org, + artifact_hash="b" * 64, + ) + + def test_member_organization_can_see_draft_invitation(self): + invitation = self.create_invitation() + + visible = ChannelInvitation.objects.visible_to_organization( + self.member_org + ) + + self.assertIn(invitation, visible) + + def test_invited_organization_cannot_see_draft_invitation(self): + invitation = self.create_invitation() + + visible = ChannelInvitation.objects.visible_to_organization( + self.invited_org + ) + + self.assertNotIn(invitation, visible) + + def test_invited_organization_can_see_ready_invitation(self): + invitation = self.create_invitation( + status=ChannelInvitation.Status.READY + ) + + visible = ChannelInvitation.objects.visible_to_organization( + self.invited_org + ) + + self.assertIn(invitation, visible) + + def test_unrelated_organization_cannot_see_ready_invitation(self): + invitation = self.create_invitation( + status=ChannelInvitation.Status.READY + ) + + visible = ChannelInvitation.objects.visible_to_organization( + self.other_org + ) + + self.assertNotIn(invitation, visible) + + def test_member_organization_can_see_canceled_invitation(self): + invitation = self.create_invitation( + status=ChannelInvitation.Status.CANCELED + ) + + visible = ChannelInvitation.objects.visible_to_organization( + self.member_org + ) + + self.assertIn(invitation, visible) + + def test_invited_organization_cannot_see_canceled_invitation(self): + invitation = self.create_invitation( + status=ChannelInvitation.Status.CANCELED + ) + + visible = ChannelInvitation.objects.visible_to_organization( + self.invited_org + ) + + self.assertNotIn(invitation, visible) + + def test_create_serializer_rejects_non_member_creator(self): + serializer = ChannelInvitationCreateBody( + data={"organization_ids": [self.invited_org.id]}, + context={ + "channel": self.channel, + "organization": self.other_org, + }, + ) + + self.assertFalse(serializer.is_valid()) + self.assertIn("non_field_errors", serializer.errors) + + def test_create_serializer_rejects_existing_member_invitee(self): + serializer = ChannelInvitationCreateBody( + data={"organization_ids": [self.second_member_org.id]}, + context={ + "channel": self.channel, + "organization": self.member_org, + }, + ) + + self.assertFalse(serializer.is_valid()) + self.assertIn("organization_ids", serializer.errors) + + def test_create_serializer_rejects_duplicate_invitees(self): + serializer = ChannelInvitationCreateBody( + data={ + "organization_ids": [ + self.invited_org.id, + self.invited_org.id, + ] + }, + context={ + "channel": self.channel, + "organization": self.member_org, + }, + ) + + self.assertFalse(serializer.is_valid()) + self.assertIn("organization_ids", serializer.errors) + + def test_create_serializer_rejects_too_many_required_signatures(self): + serializer = ChannelInvitationCreateBody( + data={ + "organization_ids": [self.invited_org.id], + "required_signatures": 3, + }, + context={ + "channel": self.channel, + "organization": self.member_org, + }, + ) + + self.assertFalse(serializer.is_valid()) + self.assertIn("required_signatures", serializer.errors) + + def test_create_serializer_defaults_required_signatures(self): + serializer = ChannelInvitationCreateBody( + data={"organization_ids": [self.invited_org.id]}, + context={ + "channel": self.channel, + "organization": self.member_org, + }, + ) + + self.assertTrue(serializer.is_valid(), serializer.errors) + invitation = serializer.save() + + self.assertEqual(invitation.required_signatures, 2) + + def test_create_serializer_creates_invitation_and_invitees(self): + serializer = ChannelInvitationCreateBody( + data={ + "organization_ids": [self.invited_org.id], + "required_signatures": 1, + }, + context={ + "channel": self.channel, + "organization": self.member_org, + }, + ) + + self.assertTrue(serializer.is_valid(), serializer.errors) + invitation = serializer.save() + + self.assertEqual(invitation.channel, self.channel) + self.assertEqual(invitation.creator_organization, self.member_org) + self.assertEqual(invitation.required_signatures, 1) + self.assertEqual(invitation.invitees.count(), 1) + self.assertEqual( + invitation.invitees.get().organization, + self.invited_org, + ) diff --git a/src/nodes/hyperledger-fabric/Dockerfile b/src/nodes/hyperledger-fabric/Dockerfile index 5fe7bbae1..9460fbfc5 100644 --- a/src/nodes/hyperledger-fabric/Dockerfile +++ b/src/nodes/hyperledger-fabric/Dockerfile @@ -21,7 +21,7 @@ # Workdir is set to $GOPATH/src/github.com/hyperledger/fabric # Data is stored under /var/hyperledger/production -FROM golang:1.25.5 +FROM golang:1.26.2 LABEL maintainer="Baohua Yang " # Orderer, peer, ca, operation api @@ -34,10 +34,10 @@ ENV FABRIC_ROOT=$GOPATH/src/github.com/hyperledger/fabric \ FABRIC_CA_ROOT=$GOPATH/src/github.com/hyperledger/fabric-ca # BASE_VERSION is used in metadata.Version as major version -ENV BASE_VERSION=2.5.14 +ENV BASE_VERSION=2.5.15 # PROJECT_VERSION is required in core.yaml for fabric-baseos and fabric-ccenv -ENV PROJECT_VERSION=2.5.14 +ENV PROJECT_VERSION=2.5.15 ENV HLF_CA_VERSION=1.5.16 # generic environment (core.yaml) for builder and runtime: e.g., builder: $(DOCKER_NS)/fabric-ccenv:$(TWO_DIGIT_VERSION), golang, java, node diff --git a/tests/postman/collections/Cello Engine CI Test.postman_collection.json b/tests/postman/collections/Cello Engine CI Test.postman_collection.json index fa5c4e7b0..7aba5ae8c 100644 --- a/tests/postman/collections/Cello Engine CI Test.postman_collection.json +++ b/tests/postman/collections/Cello Engine CI Test.postman_collection.json @@ -1,10 +1,7 @@ { "info": { - "_postman_id": "04314160-39b9-401c-b74f-6ea9f1cd89fd", "name": "Cello Engine CI Test", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", - "_exporter_id": "43793131", - "_collection_link": "https://www.postman.com/haiboyang-3238546/workspace/hyperledger-cello/collection/43793131-04314160-39b9-401c-b74f-6ea9f1cd89fd?action=share&source=collection_link&creator=43793131" + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "item": [ { @@ -14,14 +11,9 @@ "listen": "test", "script": { "exec": [ - "pm.test(\"Status code is 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "if (pm.response.code === 200) {", - " var jsonData = pm.response.json();", - " pm.environment.set(\"org_id\", jsonData.data.id);", - "}" + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + "});" ], "type": "text/javascript" } @@ -38,7 +30,7 @@ ], "body": { "mode": "raw", - "raw": "{\n\t\"email\": \"foo@email.com\",\n\t\"password\": \"foo\",\n\t\"orgName\": \"org1.foo.com\"\n}" + "raw": "{\n\t\"email\": \"foo@email.com\",\n\t\"password\": \"foo\",\n\t\"org_name\": \"org1.foo.com\",\n\t\"agent_url\": \"http://cello-hyperledger-fabric-agent:8080/api/v1/\"\n}" }, "url": { "raw": "{{base_url}}/api/v1/register", @@ -102,59 +94,7 @@ } }, { - "name": "Create Agent", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code is 201\", function () {", - " pm.response.to.have.status(201);", - "});", - "", - "if (pm.response.code === 201) {", - " var jsonData = pm.response.json();", - " pm.environment.set(\"agent_id\", jsonData.data.id);", - "}" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - }, - { - "key": "Authorization", - "value": "JWT {{token}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n\t\"name\": \"cello-agent-docker\",\n\t\"type\": \"docker\",\n\t\"urls\": \"http://cello.docker.agent:5001\"\n}" - }, - "url": { - "raw": "{{base_url}}/api/v1/agents", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "v1", - "agents" - ] - }, - "description": "Create new agent" - } - }, - { - "name": "Create Peer Node", + "name": "Create A Peer", "event": [ { "listen": "test", @@ -162,12 +102,7 @@ "exec": [ "pm.test(\"Status code is 201\", function () {", " pm.response.to.have.status(201);", - "});", - "", - "if (pm.response.code === 201) {", - " var jsonData = pm.response.json();", - " pm.environment.set(\"peer_id\", jsonData.data.id);", - "}" + "});" ], "type": "text/javascript" } @@ -189,7 +124,7 @@ ], "body": { "mode": "raw", - "raw": "{\n\t\"name\": \"peer0\",\n\t\"type\": \"peer\"\n}" + "raw": "{\n\t\"name\": \"peer0\",\n\t\"type\": \"PEER\"\n}" }, "url": { "raw": "{{base_url}}/api/v1/nodes", @@ -202,11 +137,11 @@ "nodes" ] }, - "description": "Create new peer node" + "description": "Create a new peer" } }, { - "name": "Create Orderer Node", + "name": "Create An Orderer", "event": [ { "listen": "test", @@ -214,12 +149,7 @@ "exec": [ "pm.test(\"Status code is 201\", function () {", " pm.response.to.have.status(201);", - "});", - "", - "if (pm.response.code === 201) {", - " var jsonData = pm.response.json();", - " pm.environment.set(\"orderer_id\", jsonData.data.id);", - "}" + "});" ], "type": "text/javascript" } @@ -241,7 +171,7 @@ ], "body": { "mode": "raw", - "raw": "{\n\t\"name\": \"orderer0\",\n\t\"type\": \"orderer\"\n}" + "raw": "{\n\t\"name\": \"orderer0\",\n\t\"type\": \"ORDERER\"\n}" }, "url": { "raw": "{{base_url}}/api/v1/nodes", @@ -254,11 +184,11 @@ "nodes" ] }, - "description": "Create new orderer node" + "description": "Create a new orderer" } }, { - "name": "Create Network", + "name": "Create A Channel", "event": [ { "listen": "test", @@ -270,11 +200,7 @@ "", "if (pm.response.code === 201) {", " var jsonData = pm.response.json();", - " pm.environment.set(\"network_id\", jsonData.data.id);", - " ", - " setTimeout(function() {", - " console.log(\"Delayed 5 seconds\");", - " }, 5000);", + " pm.environment.set(\"channel_id\", jsonData.data.id);", "}" ], "type": "text/javascript" @@ -297,24 +223,24 @@ ], "body": { "mode": "raw", - "raw": "{\n\t\"name\": \"test-network\",\n\t\"consensus\": \"etcdraft\",\n\t\"database\": \"couchdb\"\n}" + "raw": "{\n\t\"name\": \"channel0\"\n}" }, "url": { - "raw": "{{base_url}}/api/v1/networks", + "raw": "{{base_url}}/api/v1/channels", "host": [ "{{base_url}}" ], "path": [ "api", "v1", - "networks" + "channels" ] }, - "description": "Create new blockchain network" + "description": "Create a new channel" } }, { - "name": "Create Channel", + "name": "Upload a Chaincode", "event": [ { "listen": "test", @@ -326,11 +252,7 @@ "", "if (pm.response.code === 201) {", " var jsonData = pm.response.json();", - " pm.environment.set(\"channel_id\", jsonData.id);", - " ", - " setTimeout(function() {", - " console.log(\"Delayed 10 seconds\");", - " }, 10000);", + " pm.environment.set(\"chaincode_id\", jsonData.data.id);", "}" ], "type": "text/javascript" @@ -340,11 +262,6 @@ "request": { "method": "POST", "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - }, { "key": "Authorization", "value": "JWT {{token}}", @@ -352,25 +269,51 @@ } ], "body": { - "mode": "raw", - "raw": "{\n\t\"name\": \"mychannel\",\n\t\"orderers\": [\"{{orderer_id}}\"],\n\t\"peers\": [\"{{peer_id}}\"]\n}" + "mode": "formdata", + "formdata": [ + { + "key": "name", + "value": "basic", + "type": "text" + }, + { + "key": "version", + "value": "1.0", + "type": "text" + }, + { + "key": "sequence", + "value": "1", + "type": "text" + }, + { + "key": "package", + "type": "file", + "src": ["basic.tar.gz"] + }, + { + "key": "channel", + "value": "{{channel_id}}", + "type": "text" + } + ] }, "url": { - "raw": "{{base_url}}/api/v1/channels", + "raw": "{{base_url}}/api/v1/chaincodes", "host": [ "{{base_url}}" ], "path": [ "api", "v1", - "channels" + "chaincodes" ] }, - "description": "Create new channel with specified peers and orderers" + "description": "Create a new chaincode" } }, { - "name": "Upload Chaincode Package", + "name": "Wait for the chaincode to be approved", "event": [ { "listen": "test", @@ -385,63 +328,40 @@ } ], "request": { - "method": "POST", - "header": [ - { - "key": "Authorization", - "value": "JWT {{token}}", - "type": "text" - } - ], - "body": { - "mode": "formdata", - "formdata": [ - { - "key": "file", - "type": "file", - "src": ["basic.tar.gz"], - "description": "basic chaincode package" - }, - { - "key": "description", - "value": "Sample chaincode for testing", - "type": "text" - } - ] - }, + "method": "GET", "url": { - "raw": "{{base_url}}/api/v1/chaincodes/chaincodeRepo", + "raw": "https://postman-echo.com/delay/10", "host": [ - "{{base_url}}" + "postman-echo.com" ], "path": [ - "api", - "v1", - "chaincodes", - "chaincodeRepo" - ] + "delay", + "10" + ], + "query": [] }, - "description": "Upload chaincode package file" + "description": "Wait a minute" } }, { - "name": "List Chaincodes", + "name": "Check if the chaincode is approved", "event": [ { "listen": "test", "script": { "exec": [ - "pm.test(\"Status code is 200\", function () {", - " pm.response.to.have.status(200);", - "});", + "let json = pm.response.json();", + "let chaincode = json.data.data[0];", + "let status = chaincode.status;", "", - "if (pm.response.code === 200) {", - " var jsonData = pm.response.json();", - " if (jsonData.data && jsonData.data.data && jsonData.data.data.length > 0) {", - " var latestChaincode = jsonData.data.data[0];", - " pm.environment.set(\"package_id\", latestChaincode.package_id);", + "let retries = pm.collectionVariables.get(\"retries\");", + "if (status !== \"APPROVED\") {", + " if (retries < 30) {", + " console.log(`Not ready (status=${status}), retry #${retries}`);", + " pm.collectionVariables.set(\"retries\", retries + 1);", + " pm.execution.setNextRequest(\"Wait for the chaincode to be approved\");", " } else {", - " console.log(\"No chaincodes found in response\");", + " pm.expect.fail(`Max retries reached. Last status: ${status}`);", " }", "}" ], @@ -479,114 +399,18 @@ } ] }, - "description": "List chaincodes to get the latest uploaded chaincode ID" - } - }, - { - "name": "Install Chaincode", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code is 200\", function () {", - " pm.response.to.have.status(200);", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - }, - { - "key": "Authorization", - "value": "JWT {{token}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n\t\"id\": \"{{package_id}}\",\n\t\"node\": \"{{peer_id}}\"\n}" - }, - "url": { - "raw": "{{base_url}}/api/v1/chaincodes/install", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "v1", - "chaincodes", - "install" - ] - }, - "description": "Install chaincode package to peer node" - } - }, - { - "name": "Approve Chaincode", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code is 200\", function () {", - " pm.response.to.have.status(200);", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - }, - { - "key": "Authorization", - "value": "JWT {{token}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n\t\"channel_name\": \"mychannel\",\n\t\"chaincode_name\": \"basic\",\n\t\"chaincode_version\": \"1.0\",\n\t\"sequence\": 1,\n\t\"policy\": \"\",\n\t\"init_flag\": false\n}" - }, - "url": { - "raw": "{{base_url}}/api/v1/chaincodes/approve_for_my_org", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "v1", - "chaincodes", - "approve_for_my_org" - ] - }, - "description": "Approve chaincode for organization" + "description": "List chaincodes to check if it's been approved" } }, { - "name": "Commit Chaincode", + "name": "Commit the chaincode", "event": [ { "listen": "test", "script": { "exec": [ - "pm.test(\"Status code is 200\", function () {", - " pm.response.to.have.status(200);", + "pm.test(\"Status code is 204\", function () {", + " pm.response.to.have.status(204);", "});" ], "type": "text/javascript" @@ -594,7 +418,7 @@ } ], "request": { - "method": "POST", + "method": "PUT", "header": [ { "key": "Content-Type", @@ -607,12 +431,9 @@ "type": "text" } ], - "body": { - "mode": "raw", - "raw": "{\n\t\"channel_name\": \"mychannel\",\n\t\"chaincode_name\": \"basic\",\n\t\"chaincode_version\": \"1.0\",\n\t\"sequence\": 1,\n\t\"policy\": \"\",\n\t\"init_flag\": false\n}" - }, + "body": {}, "url": { - "raw": "{{base_url}}/api/v1/chaincodes/commit", + "raw": "{{base_url}}/api/v1/chaincodes/{{chaincode_id}}/commit", "host": [ "{{base_url}}" ], @@ -620,10 +441,11 @@ "api", "v1", "chaincodes", + "{{chaincode_id}}", "commit" ] }, - "description": "Commit chaincode definition to channel" + "description": "Commit the chaincode" } } ], @@ -639,37 +461,17 @@ "type": "string" }, { - "key": "org_id", - "value": "", - "type": "string" - }, - { - "key": "agent_id", - "value": "", - "type": "string" - }, - { - "key": "network_id", - "value": "", - "type": "string" - }, - { - "key": "peer_id", - "value": "", - "type": "string" - }, - { - "key": "orderer_id", + "key": "channel_id", "value": "", "type": "string" }, { - "key": "channel_id", - "value": "", - "type": "string" + "key": "retries", + "value": 0, + "type": "number" }, { - "key": "package_id", + "key": "chaincode_id", "value": "", "type": "string" } diff --git a/tests/postman/docker-compose.dev.yml b/tests/postman/docker-compose.yml similarity index 94% rename from tests/postman/docker-compose.dev.yml rename to tests/postman/docker-compose.yml index d23b72e16..33d42529a 100644 --- a/tests/postman/docker-compose.dev.yml +++ b/tests/postman/docker-compose.yml @@ -1,4 +1,3 @@ -version: '3.2' services: newman: image: postman/newman:latest