diff --git a/docs/source/api-persistence.rst b/docs/source/api-persistence.rst index ac24b0a2f5..c39694b5e7 100644 --- a/docs/source/api-persistence.rst +++ b/docs/source/api-persistence.rst @@ -145,6 +145,8 @@ which is then referenced within a custom flow: :start-after: DOCSTART TopupIssuer :end-before: DOCEND TopupIssuer +For examples on testing ``@CordaService`` implementations, see the oracle example :doc:`here ` + .. _database_migration_ref: Database Migration diff --git a/docs/source/api-testing.rst b/docs/source/api-testing.rst index d8a2511ccc..6a04804b88 100644 --- a/docs/source/api-testing.rst +++ b/docs/source/api-testing.rst @@ -59,6 +59,8 @@ object, or by using named paramters in Kotlin: .. sourcecode:: kotlin val network = MockNetwork( + // A list of packages to scan. Any contracts, flows and Corda services within these + // packages will be automatically available to any nodes within the mock network cordappPackages = listOf("my.cordapp.package", "my.other.cordapp.package"), // If true then each node will be run in its own thread. This can result in race conditions in your // code if not carefully written, but is more realistic and may help if you have flows in your app that @@ -77,7 +79,10 @@ object, or by using named paramters in Kotlin: // notary implementations. servicePeerAllocationStrategy = InMemoryMessagingNetwork.ServicePeerAllocationStrategy.Random()) - val network2 = MockNetwork(listOf("my.cordapp.package", "my.other.cordapp.package"), MockNetworkParameters( + val network2 = MockNetwork( + // A list of packages to scan. Any contracts, flows and Corda services within these + // packages will be automatically available to any nodes within the mock network + listOf("my.cordapp.package", "my.other.cordapp.package"), MockNetworkParameters( // If true then each node will be run in its own thread. This can result in race conditions in your // code if not carefully written, but is more realistic and may help if you have flows in your app that // do long blocking operations. @@ -98,7 +103,10 @@ object, or by using named paramters in Kotlin: .. sourcecode:: java - MockNetwork network = MockNetwork(ImmutableList.of("my.cordapp.package", "my.other.cordapp.package"), + MockNetwork network = MockNetwork( + // A list of packages to scan. Any contracts, flows and Corda services within these + // packages will be automatically available to any nodes within the mock network + ImmutableList.of("my.cordapp.package", "my.other.cordapp.package"), new MockNetworkParameters() // If true then each node will be run in its own thread. This can result in race conditions in // your code if not carefully written, but is more realistic and may help if you have flows in @@ -294,6 +302,7 @@ Further examples ^^^^^^^^^^^^^^^^ * See the flow testing tutorial :doc:`here ` +* See the oracle tutorial :doc:`here ` for information on testing ``@CordaService`` classes * Further examples are available in the Example CorDapp in `Java `_ and `Kotlin `_ diff --git a/docs/source/oracles.rst b/docs/source/oracles.rst index 7c1fa36f53..5066fe2f93 100644 --- a/docs/source/oracles.rst +++ b/docs/source/oracles.rst @@ -269,7 +269,26 @@ Here's an example of it in action from ``FixingFlow.Fixer``. Testing ------- -When unit testing, we make use of the ``MockNetwork`` which allows us to create ``MockNode`` instances. A ``MockNode`` -is a simplified node suitable for tests. One feature that isn't available (and which is not suitable for unit testing -anyway) is the node's ability to scan and automatically install oracles it finds in the CorDapp jars. Instead, when -working with ``MockNode``, use the ``installCordaService`` method to manually install the oracle on the relevant node. \ No newline at end of file +The ``MockNetwork`` allows the creation of ``MockNode`` instances, which are simplified nodes which can be used for +testing (see :doc:`api-testing`). When creating the ``MockNetwork`` you supply a list of packages to scan for CorDapps. +Make sure the packages you provide include your oracle service, and it automatically be installed in the test nodes. +Then you can create an oracle node on the ``MockNetwork`` and insert any initialisation logic you want to use. In this +case, our ``Oracle`` service is in the ``net.corda.irs.api`` package, so the following test setup will install +the service in each node. Then an oracle node with an oracle service which is initialised with some data is created on +the mock network: + +.. literalinclude:: ../../samples/irs-demo/cordapp/src/test/kotlin/net/corda/irs/api/OracleNodeTearOffTests.kt + :language: kotlin + :start-after: DOCSTART 1 + :end-before: DOCEND 1 + :dedent: 4 + +You can then write tests on your mock network to verify the nodes interact with your Oracle correctly. + +.. literalinclude:: ../../samples/irs-demo/cordapp/src/test/kotlin/net/corda/irs/api/OracleNodeTearOffTests.kt + :language: kotlin + :start-after: DOCSTART 2 + :end-before: DOCEND 2 + :dedent: 4 + +See `here `_ for more examples. diff --git a/samples/irs-demo/cordapp/src/main/kotlin/net/corda/irs/flows/FixingFlow.kt b/samples/irs-demo/cordapp/src/main/kotlin/net/corda/irs/flows/FixingFlow.kt index 75a88974ff..5bb093319f 100644 --- a/samples/irs-demo/cordapp/src/main/kotlin/net/corda/irs/flows/FixingFlow.kt +++ b/samples/irs-demo/cordapp/src/main/kotlin/net/corda/irs/flows/FixingFlow.kt @@ -79,6 +79,8 @@ object FixingFlow { @Suspendable override fun filtering(elem: Any): Boolean { return when (elem) { + // Only expose Fix commands in which the oracle is on the list of requested signers + // to the oracle node, to avoid leaking privacy is Command<*> -> handshake.payload.oracle.owningKey in elem.signers && elem.value is Fix else -> false } @@ -91,7 +93,7 @@ object FixingFlow { } /** - * One side of the fixing flow for an interest rate swap, but could easily be generalised furher. + * One side of the fixing flow for an interest rate swap, but could easily be generalised further. * * As per the [Fixer], do not infer too much from this class name in terms of business roles. This * is just the "side" of the flow run by the party with the floating leg as a way of deciding who diff --git a/samples/irs-demo/cordapp/src/test/kotlin/net/corda/irs/api/NodeInterestRatesTest.kt b/samples/irs-demo/cordapp/src/test/kotlin/net/corda/irs/api/NodeInterestRatesTest.kt index 7cdb215e71..fa68ac46be 100644 --- a/samples/irs-demo/cordapp/src/test/kotlin/net/corda/irs/api/NodeInterestRatesTest.kt +++ b/samples/irs-demo/cordapp/src/test/kotlin/net/corda/irs/api/NodeInterestRatesTest.kt @@ -15,22 +15,15 @@ import net.corda.core.contracts.ContractState import net.corda.core.contracts.TransactionState import net.corda.core.crypto.generateKeyPair import net.corda.core.identity.CordaX500Name -import net.corda.core.identity.Party import net.corda.core.transactions.TransactionBuilder -import net.corda.core.utilities.ProgressTracker -import net.corda.core.utilities.getOrThrow import net.corda.finance.DOLLARS import net.corda.finance.contracts.Fix -import net.corda.finance.contracts.FixOf import net.corda.finance.contracts.asset.CASH import net.corda.finance.contracts.asset.Cash -import net.corda.irs.flows.RatesFixFlow import net.corda.node.internal.configureDatabase import net.corda.nodeapi.internal.persistence.CordaPersistence import net.corda.nodeapi.internal.persistence.DatabaseConfig import net.corda.testing.core.* -import net.corda.testing.internal.withoutTestSerialization -import net.corda.testing.internal.LogHelper import net.corda.testing.internal.rigorousMock import net.corda.testing.node.* import net.corda.testing.node.MockServices.Companion.makeTestDataSourceProperties @@ -220,50 +213,10 @@ class NodeInterestRatesTest { assertFailsWith { oracle.sign(ftx) } // It throws failed requirement (as it is empty there is no command to check and sign). } - @Test - fun `network tearoff`() = withoutTestSerialization { - val mockNet = MockNetwork(cordappPackages = listOf("net.corda.finance.contracts", "net.corda.irs")) - val aliceNode = mockNet.createPartyNode(ALICE_NAME) - val oracleNode = mockNet.createNode(MockNodeParameters(legalName = BOB_NAME)).apply { - registerInitiatedFlow(NodeInterestRates.FixQueryHandler::class.java) - registerInitiatedFlow(NodeInterestRates.FixSignHandler::class.java) - database.transaction { - services.cordaService(NodeInterestRates.Oracle::class.java).knownFixes = TEST_DATA - } - } - val oracle = oracleNode.services.myInfo.singleIdentity() - val tx = makePartialTX() - val fixOf = NodeInterestRates.parseFixOf("LIBOR 2016-03-16 1M") - val flow = FilteredRatesFlow(tx, oracle, fixOf, BigDecimal("0.675"), BigDecimal("0.1")) - LogHelper.setLevel("rates") - mockNet.runNetwork() - val future = aliceNode.startFlow(flow) - mockNet.runNetwork() - future.getOrThrow() - // We should now have a valid fix of our tx from the oracle. - val fix = tx.toWireTransaction(services).commands.map { it.value as Fix }.first() - assertEquals(fixOf, fix.of) - assertEquals(BigDecimal("0.678"), fix.value) - mockNet.stopNodes() - } - - class FilteredRatesFlow(tx: TransactionBuilder, - oracle: Party, - fixOf: FixOf, - expectedRate: BigDecimal, - rateTolerance: BigDecimal, - progressTracker: ProgressTracker = RatesFixFlow.tracker(fixOf.name)) - : RatesFixFlow(tx, oracle, fixOf, expectedRate, rateTolerance, progressTracker) { - override fun filtering(elem: Any): Boolean { - return when (elem) { - is Command<*> -> oracle.owningKey in elem.signers && elem.value is Fix - else -> false - } - } - } - private fun makePartialTX() = TransactionBuilder(DUMMY_NOTARY).withItems( TransactionState(1000.DOLLARS.CASH issuedBy dummyCashIssuer.party ownedBy ALICE, Cash.PROGRAM_ID, DUMMY_NOTARY)) private fun makeFullTx() = makePartialTX().withItems(dummyCommand()) } + + diff --git a/samples/irs-demo/cordapp/src/test/kotlin/net/corda/irs/api/OracleNodeTearOffTests.kt b/samples/irs-demo/cordapp/src/test/kotlin/net/corda/irs/api/OracleNodeTearOffTests.kt new file mode 100644 index 0000000000..4c27556e87 --- /dev/null +++ b/samples/irs-demo/cordapp/src/test/kotlin/net/corda/irs/api/OracleNodeTearOffTests.kt @@ -0,0 +1,155 @@ +package net.corda.irs.api + +import com.google.common.collect.testing.Helpers +import com.google.common.collect.testing.Helpers.assertContains +import net.corda.core.contracts.Command +import net.corda.core.contracts.TransactionState +import net.corda.core.flows.UnexpectedFlowEndException +import net.corda.core.identity.CordaX500Name +import net.corda.core.identity.Party +import net.corda.core.transactions.TransactionBuilder +import net.corda.core.utilities.ProgressTracker +import net.corda.core.utilities.getOrThrow +import net.corda.finance.DOLLARS +import net.corda.finance.contracts.Fix +import net.corda.finance.contracts.FixOf +import net.corda.finance.contracts.asset.CASH +import net.corda.finance.contracts.asset.Cash +import net.corda.irs.flows.RatesFixFlow +import net.corda.testing.core.* +import net.corda.testing.internal.LogHelper +import net.corda.testing.node.MockNetwork +import net.corda.testing.node.MockNodeParameters +import net.corda.testing.node.StartedMockNode +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.math.BigDecimal +import kotlin.test.assertEquals + +class OracleNodeTearOffTests { + private val TEST_DATA = NodeInterestRates.parseFile(""" + LIBOR 2016-03-16 1M = 0.678 + LIBOR 2016-03-16 2M = 0.685 + LIBOR 2016-03-16 1Y = 0.890 + LIBOR 2016-03-16 2Y = 0.962 + EURIBOR 2016-03-15 1M = 0.123 + EURIBOR 2016-03-15 2M = 0.111 + """.trimIndent()) + + private val dummyCashIssuer = TestIdentity(CordaX500Name("Cash issuer", "London", "GB")) + + val DUMMY_NOTARY = TestIdentity(DUMMY_NOTARY_NAME, 20).party + val alice = TestIdentity(ALICE_NAME, 70) + private lateinit var mockNet: MockNetwork + private lateinit var aliceNode: StartedMockNode + private lateinit var oracleNode: StartedMockNode + private val oracle get() = oracleNode.services.myInfo.singleIdentity() + + @Before + // DOCSTART 1 + fun setUp() { + mockNet = MockNetwork(cordappPackages = listOf("net.corda.finance.contracts", "net.corda.irs")) + aliceNode = mockNet.createPartyNode(ALICE_NAME) + oracleNode = mockNet.createNode(MockNodeParameters(legalName = BOB_NAME)).apply { + transaction { + services.cordaService(NodeInterestRates.Oracle::class.java).knownFixes = TEST_DATA + } + } + } + // DOCEND 1 + + @After + fun tearDown() { + mockNet.stopNodes() + } + + // DOCSTART 2 + @Test + fun `verify that the oracle signs the transaction if the interest rate within allowed limit`() { + // Create a partial transaction + val tx = TransactionBuilder(DUMMY_NOTARY) + .withItems(TransactionState(1000.DOLLARS.CASH issuedBy dummyCashIssuer.party ownedBy alice.party, Cash.PROGRAM_ID, DUMMY_NOTARY)) + // Specify the rate we wish to get verified by the oracle + val fixOf = NodeInterestRates.parseFixOf("LIBOR 2016-03-16 1M") + + // Create a new flow for the fix + val flow = FilteredRatesFlow(tx, oracle, fixOf, BigDecimal("0.675"), BigDecimal("0.1")) + // Run the mock network and wait for a result + mockNet.runNetwork() + val future = aliceNode.startFlow(flow) + mockNet.runNetwork() + future.getOrThrow() + + // We should now have a valid rate on our tx from the oracle. + val fix = tx.toWireTransaction(aliceNode.services).commands.map { it }.first() + assertEquals(fixOf, (fix.value as Fix).of) + // Check that the response contains the valid rate, which is within the supplied tolerance + assertEquals(BigDecimal("0.678"), (fix.value as Fix).value) + // Check that the transaction has been signed by the oracle + assertContains(fix.signers, oracle.owningKey) + } + // DOCEND 2 + + @Test + fun `verify that the oracle rejects the transaction if the interest rate is outside the allowed limit`() { + val tx = makePartialTX() + val fixOf = NodeInterestRates.parseFixOf("LIBOR 2016-03-16 1M") + val flow = FilteredRatesFlow(tx, oracle, fixOf, BigDecimal("0.695"), BigDecimal("0.01")) + LogHelper.setLevel("rates") + + mockNet.runNetwork() + val future = aliceNode.startFlow(flow) + mockNet.runNetwork() + assertThatThrownBy{ + future.getOrThrow() + }.isInstanceOf(RatesFixFlow.FixOutOfRange::class.java).hasMessage("Fix out of range by 0.017") + } + + @Test + fun `verify that the oracle rejects the transaction if there is a privacy leak`() { + val tx = makePartialTX() + val fixOf = NodeInterestRates.parseFixOf("LIBOR 2016-03-16 1M") + val flow = OverFilteredRatesFlow(tx, oracle, fixOf, BigDecimal("0.675"), BigDecimal("0.1")) + LogHelper.setLevel("rates") + + mockNet.runNetwork() + val future = aliceNode.startFlow(flow) + mockNet.runNetwork() + //The oracle + assertThatThrownBy{ + future.getOrThrow() + }.isInstanceOf(UnexpectedFlowEndException::class.java) + } + + // Creates a version of [RatesFixFlow] that makes the command + class FilteredRatesFlow(tx: TransactionBuilder, + oracle: Party, + fixOf: FixOf, + expectedRate: BigDecimal, + rateTolerance: BigDecimal, + progressTracker: ProgressTracker = tracker(fixOf.name)) + : RatesFixFlow(tx, oracle, fixOf, expectedRate, rateTolerance, progressTracker) { + override fun filtering(elem: Any): Boolean { + return when (elem) { + is Command<*> -> oracle.owningKey in elem.signers && elem.value is Fix + else -> false + } + } + } + + // Creates a version of [RatesFixFlow] that makes the command + class OverFilteredRatesFlow(tx: TransactionBuilder, + oracle: Party, + fixOf: FixOf, + expectedRate: BigDecimal, + rateTolerance: BigDecimal, + progressTracker: ProgressTracker = tracker(fixOf.name)) + : RatesFixFlow(tx, oracle, fixOf, expectedRate, rateTolerance, progressTracker) { + override fun filtering(elem: Any): Boolean = true + } + + private fun makePartialTX() = TransactionBuilder(DUMMY_NOTARY).withItems( + TransactionState(1000.DOLLARS.CASH issuedBy dummyCashIssuer.party ownedBy alice.party, Cash.PROGRAM_ID, DUMMY_NOTARY)) +} \ No newline at end of file