mirror of
https://github.com/corda/corda.git
synced 2025-04-15 15:07:03 +00:00
Merge OS
This commit is contained in:
commit
a63c9526ca
@ -10,55 +10,60 @@
|
||||
|
||||
package net.corda.confidential
|
||||
|
||||
import com.natpryce.hamkrest.MatchResult
|
||||
import com.natpryce.hamkrest.Matcher
|
||||
import com.natpryce.hamkrest.equalTo
|
||||
import net.corda.core.identity.*
|
||||
import net.corda.core.utilities.getOrThrow
|
||||
import net.corda.testing.core.*
|
||||
import net.corda.testing.internal.matchers.allOf
|
||||
import net.corda.testing.internal.matchers.flow.willReturn
|
||||
import net.corda.testing.node.internal.InternalMockNetwork
|
||||
import net.corda.testing.node.internal.startFlow
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import kotlin.test.*
|
||||
import com.natpryce.hamkrest.assertion.assert
|
||||
import net.corda.core.crypto.DigitalSignature
|
||||
import net.corda.testing.internal.matchers.hasOnlyEntries
|
||||
import net.corda.testing.node.internal.TestStartedNode
|
||||
import org.junit.AfterClass
|
||||
import java.security.PublicKey
|
||||
|
||||
class SwapIdentitiesFlowTests {
|
||||
private lateinit var mockNet: InternalMockNetwork
|
||||
companion object {
|
||||
private val mockNet = InternalMockNetwork(networkSendManuallyPumped = false, threadPerNode = true)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
// We run this in parallel threads to help catch any race conditions that may exist.
|
||||
mockNet = InternalMockNetwork(networkSendManuallyPumped = false, threadPerNode = true)
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun tearDown() = mockNet.stopNodes()
|
||||
}
|
||||
|
||||
private val aliceNode = mockNet.createPartyNode(makeUnique(ALICE_NAME))
|
||||
private val bobNode = mockNet.createPartyNode(makeUnique(BOB_NAME))
|
||||
private val charlieNode = mockNet.createPartyNode(makeUnique(CHARLIE_NAME))
|
||||
private val alice = aliceNode.info.singleIdentity()
|
||||
private val bob = bobNode.info.singleIdentity()
|
||||
|
||||
@Test
|
||||
fun `issue key`() {
|
||||
// Set up values we'll need
|
||||
val aliceNode = mockNet.createPartyNode(ALICE_NAME)
|
||||
val bobNode = mockNet.createPartyNode(BOB_NAME)
|
||||
val alice = aliceNode.info.singleIdentity()
|
||||
val bob = bobNode.services.myInfo.singleIdentity()
|
||||
|
||||
// Run the flows
|
||||
val requesterFlow = aliceNode.services.startFlow(SwapIdentitiesFlow(bob)).resultFuture
|
||||
|
||||
// Get the results
|
||||
val actual: Map<Party, AnonymousParty> = requesterFlow.getOrThrow().toMap()
|
||||
assertEquals(2, actual.size)
|
||||
// Verify that the generated anonymous identities do not match the well known identities
|
||||
val aliceAnonymousIdentity = actual[alice] ?: throw IllegalStateException()
|
||||
val bobAnonymousIdentity = actual[bob] ?: throw IllegalStateException()
|
||||
assertNotEquals<AbstractParty>(alice, aliceAnonymousIdentity)
|
||||
assertNotEquals<AbstractParty>(bob, bobAnonymousIdentity)
|
||||
|
||||
// Verify that the anonymous identities look sane
|
||||
assertEquals(alice.name, aliceNode.database.transaction { aliceNode.services.identityService.wellKnownPartyFromAnonymous(aliceAnonymousIdentity)!!.name })
|
||||
assertEquals(bob.name, bobNode.database.transaction { bobNode.services.identityService.wellKnownPartyFromAnonymous(bobAnonymousIdentity)!!.name })
|
||||
|
||||
// Verify that the nodes have the right anonymous identities
|
||||
assertTrue { aliceAnonymousIdentity.owningKey in aliceNode.services.keyManagementService.keys }
|
||||
assertTrue { bobAnonymousIdentity.owningKey in bobNode.services.keyManagementService.keys }
|
||||
assertFalse { aliceAnonymousIdentity.owningKey in bobNode.services.keyManagementService.keys }
|
||||
assertFalse { bobAnonymousIdentity.owningKey in aliceNode.services.keyManagementService.keys }
|
||||
|
||||
mockNet.stopNodes()
|
||||
assert.that(
|
||||
aliceNode.services.startFlow(SwapIdentitiesFlow(bob)),
|
||||
willReturn(
|
||||
hasOnlyEntries(
|
||||
alice to allOf(
|
||||
!equalTo<AbstractParty>(alice),
|
||||
aliceNode.resolvesToWellKnownParty(alice),
|
||||
aliceNode.holdsOwningKey(),
|
||||
!bobNode.holdsOwningKey()
|
||||
),
|
||||
bob to allOf(
|
||||
!equalTo<AbstractParty>(bob),
|
||||
bobNode.resolvesToWellKnownParty(bob),
|
||||
bobNode.holdsOwningKey(),
|
||||
!aliceNode.holdsOwningKey()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -66,58 +71,101 @@ class SwapIdentitiesFlowTests {
|
||||
*/
|
||||
@Test
|
||||
fun `verifies identity name`() {
|
||||
// Set up values we'll need
|
||||
val aliceNode = mockNet.createPartyNode(ALICE_NAME)
|
||||
val bobNode = mockNet.createPartyNode(BOB_NAME)
|
||||
val charlieNode = mockNet.createPartyNode(CHARLIE_NAME)
|
||||
val bob: Party = bobNode.services.myInfo.singleIdentity()
|
||||
val notBob = charlieNode.database.transaction {
|
||||
charlieNode.services.keyManagementService.freshKeyAndCert(charlieNode.services.myInfo.singleIdentityAndCert(), false)
|
||||
val notBob = charlieNode.issueFreshKeyAndCert()
|
||||
val signature = charlieNode.signSwapIdentitiesFlowData(notBob, notBob.owningKey)
|
||||
assertFailsWith<SwapIdentitiesException>(
|
||||
"Certificate subject must match counterparty's well known identity.") {
|
||||
aliceNode.validateSwapIdentitiesFlow(bob, notBob, signature)
|
||||
}
|
||||
val sigData = SwapIdentitiesFlow.buildDataToSign(notBob)
|
||||
val signature = charlieNode.services.keyManagementService.sign(sigData, notBob.owningKey)
|
||||
assertFailsWith<SwapIdentitiesException>("Certificate subject must match counterparty's well known identity.") {
|
||||
SwapIdentitiesFlow.validateAndRegisterIdentity(aliceNode.services.identityService, bob, notBob, signature.withoutKey())
|
||||
}
|
||||
|
||||
mockNet.stopNodes()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that flow is actually validating its the signature presented by the counterparty.
|
||||
*/
|
||||
@Test
|
||||
fun `verifies signature`() {
|
||||
// Set up values we'll need
|
||||
val aliceNode = mockNet.createPartyNode(ALICE_NAME)
|
||||
val bobNode = mockNet.createPartyNode(BOB_NAME)
|
||||
val alice: PartyAndCertificate = aliceNode.info.singleIdentityAndCert()
|
||||
val bob: PartyAndCertificate = bobNode.info.singleIdentityAndCert()
|
||||
// Check that the right name but wrong key is rejected
|
||||
val evilBobNode = mockNet.createPartyNode(BOB_NAME)
|
||||
fun `verification rejects signature if name is right but key is wrong`() {
|
||||
val evilBobNode = mockNet.createPartyNode(bobNode.info.singleIdentity().name)
|
||||
val evilBob = evilBobNode.info.singleIdentityAndCert()
|
||||
evilBobNode.database.transaction {
|
||||
val anonymousEvilBob = evilBobNode.services.keyManagementService.freshKeyAndCert(evilBob, false)
|
||||
val sigData = SwapIdentitiesFlow.buildDataToSign(evilBob)
|
||||
val signature = evilBobNode.services.keyManagementService.sign(sigData, anonymousEvilBob.owningKey)
|
||||
assertFailsWith<SwapIdentitiesException>("Signature does not match the given identity and nonce") {
|
||||
SwapIdentitiesFlow.validateAndRegisterIdentity(aliceNode.services.identityService, bob.party, anonymousEvilBob, signature.withoutKey())
|
||||
}
|
||||
}
|
||||
// Check that the right signing key, but wrong identity is rejected
|
||||
val anonymousAlice: PartyAndCertificate = aliceNode.database.transaction {
|
||||
aliceNode.services.keyManagementService.freshKeyAndCert(alice, false)
|
||||
}
|
||||
bobNode.database.transaction {
|
||||
bobNode.services.keyManagementService.freshKeyAndCert(bob, false)
|
||||
}.let { anonymousBob ->
|
||||
val sigData = SwapIdentitiesFlow.buildDataToSign(anonymousAlice)
|
||||
val signature = bobNode.services.keyManagementService.sign(sigData, anonymousBob.owningKey)
|
||||
assertFailsWith<SwapIdentitiesException>("Signature does not match the given identity and nonce.") {
|
||||
SwapIdentitiesFlow.validateAndRegisterIdentity(aliceNode.services.identityService, bob.party, anonymousBob, signature.withoutKey())
|
||||
}
|
||||
}
|
||||
val anonymousEvilBob = evilBobNode.issueFreshKeyAndCert()
|
||||
val signature = evilBobNode.signSwapIdentitiesFlowData(evilBob, anonymousEvilBob.owningKey)
|
||||
|
||||
mockNet.stopNodes()
|
||||
assertFailsWith<SwapIdentitiesException>(
|
||||
"Signature does not match the given identity and nonce") {
|
||||
aliceNode.validateSwapIdentitiesFlow(bob, anonymousEvilBob, signature)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verification rejects signature if key is right but name is wrong`() {
|
||||
val anonymousAlice = aliceNode.issueFreshKeyAndCert()
|
||||
val anonymousBob = bobNode.issueFreshKeyAndCert()
|
||||
val signature = bobNode.signSwapIdentitiesFlowData(anonymousAlice, anonymousBob.owningKey)
|
||||
|
||||
assertFailsWith<SwapIdentitiesException>(
|
||||
"Signature does not match the given identity and nonce.") {
|
||||
aliceNode.validateSwapIdentitiesFlow(bob, anonymousBob, signature)
|
||||
}
|
||||
}
|
||||
|
||||
//region Operations
|
||||
private fun TestStartedNode.issueFreshKeyAndCert() = database.transaction {
|
||||
services.keyManagementService.freshKeyAndCert(services.myInfo.singleIdentityAndCert(), false)
|
||||
}
|
||||
|
||||
private fun TestStartedNode.signSwapIdentitiesFlowData(party: PartyAndCertificate, owningKey: PublicKey) =
|
||||
services.keyManagementService.sign(
|
||||
SwapIdentitiesFlow.buildDataToSign(party),
|
||||
owningKey)
|
||||
|
||||
private fun TestStartedNode.validateSwapIdentitiesFlow(
|
||||
party: Party,
|
||||
counterparty: PartyAndCertificate,
|
||||
signature: DigitalSignature.WithKey) =
|
||||
SwapIdentitiesFlow.validateAndRegisterIdentity(
|
||||
services.identityService,
|
||||
party,
|
||||
counterparty,
|
||||
signature.withoutKey()
|
||||
)
|
||||
//endregion
|
||||
|
||||
//region Matchers
|
||||
private fun TestStartedNode.resolvesToWellKnownParty(party: Party) = object : Matcher<AnonymousParty> {
|
||||
override val description = """
|
||||
is resolved by "${this@resolvesToWellKnownParty.info.singleIdentity().name}" to well-known party "${party.name}"
|
||||
""".trimIndent()
|
||||
|
||||
override fun invoke(actual: AnonymousParty): MatchResult {
|
||||
val resolvedName = services.identityService.wellKnownPartyFromAnonymous(actual)!!.name
|
||||
return if (resolvedName == party.name) {
|
||||
MatchResult.Match
|
||||
} else {
|
||||
MatchResult.Mismatch("was resolved to $resolvedName")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class HoldsOwningKeyMatcher(val node: TestStartedNode, val negated: Boolean = false) : Matcher<AnonymousParty> {
|
||||
private fun sayNotIf(negation: Boolean) = if (negation) { "not " } else { "" }
|
||||
|
||||
override val description =
|
||||
"has an owning key which is ${sayNotIf(negated)}held by ${node.info.singleIdentity().name}"
|
||||
|
||||
override fun invoke(actual: AnonymousParty) =
|
||||
if (negated != actual.owningKey in node.services.keyManagementService.keys) {
|
||||
MatchResult.Match
|
||||
} else {
|
||||
MatchResult.Mismatch("""
|
||||
had an owning key which was ${sayNotIf(!negated)}held by ${node.info.singleIdentity().name}
|
||||
""".trimIndent())
|
||||
}
|
||||
|
||||
override fun not(): Matcher<AnonymousParty> {
|
||||
return copy(negated=!negated)
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestStartedNode.holdsOwningKey() = HoldsOwningKeyMatcher(this)
|
||||
//endregion
|
||||
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ package net.corda.deterministic.common
|
||||
import net.corda.core.contracts.Attachment
|
||||
import net.corda.core.contracts.ContractAttachment
|
||||
import net.corda.core.contracts.ContractClassName
|
||||
import net.corda.core.internal.TEST_UPLOADER
|
||||
import net.corda.core.internal.DEPLOYED_CORDAPP_UPLOADER
|
||||
import net.corda.core.serialization.CordaSerializable
|
||||
import net.corda.core.serialization.SerializedBytes
|
||||
import net.corda.core.serialization.deserialize
|
||||
@ -18,8 +18,9 @@ class TransactionVerificationRequest(val wtxToVerify: SerializedBytes<WireTransa
|
||||
fun toLedgerTransaction(): LedgerTransaction {
|
||||
val deps = dependencies.map { it.deserialize() }.associateBy(WireTransaction::id)
|
||||
val attachments = attachments.map { it.deserialize<Attachment>() }
|
||||
val attachmentMap = attachments.mapNotNull { it as? MockContractAttachment }
|
||||
.associateBy(Attachment::id) { ContractAttachment(it, it.contract, uploader=TEST_UPLOADER) }
|
||||
val attachmentMap = attachments
|
||||
.mapNotNull { it as? MockContractAttachment }
|
||||
.associateBy(Attachment::id) { ContractAttachment(it, it.contract, uploader = DEPLOYED_CORDAPP_UPLOADER) }
|
||||
val contractAttachmentMap = emptyMap<ContractClassName, ContractAttachment>()
|
||||
@Suppress("DEPRECATION")
|
||||
return wtxToVerify.deserialize().toLedgerTransaction(
|
||||
|
@ -26,15 +26,14 @@ import java.security.CodeSigner
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.jar.JarInputStream
|
||||
|
||||
// Possible attachment uploaders
|
||||
const val DEPLOYED_CORDAPP_UPLOADER = "app"
|
||||
const val RPC_UPLOADER = "rpc"
|
||||
const val TEST_UPLOADER = "test"
|
||||
const val P2P_UPLOADER = "p2p"
|
||||
const val UNKNOWN_UPLOADER = "unknown"
|
||||
|
||||
fun isUploaderTrusted(uploader: String?) =
|
||||
uploader?.let { it in listOf(DEPLOYED_CORDAPP_UPLOADER, RPC_UPLOADER, TEST_UPLOADER) } ?: false
|
||||
private val TRUSTED_UPLOADERS = listOf(DEPLOYED_CORDAPP_UPLOADER, RPC_UPLOADER)
|
||||
|
||||
fun isUploaderTrusted(uploader: String?): Boolean = uploader in TRUSTED_UPLOADERS
|
||||
|
||||
@KeepForDJVM
|
||||
abstract class AbstractAttachment(dataLoader: () -> ByteArray) : Attachment {
|
||||
|
57
core/src/main/kotlin/net/corda/core/internal/CordaUtils.kt
Normal file
57
core/src/main/kotlin/net/corda/core/internal/CordaUtils.kt
Normal file
@ -0,0 +1,57 @@
|
||||
package net.corda.core.internal
|
||||
|
||||
import net.corda.core.DeleteForDJVM
|
||||
import net.corda.core.cordapp.Cordapp
|
||||
import net.corda.core.cordapp.CordappConfig
|
||||
import net.corda.core.cordapp.CordappContext
|
||||
import net.corda.core.crypto.SecureHash
|
||||
import net.corda.core.flows.FlowLogic
|
||||
import net.corda.core.node.ServicesForResolution
|
||||
import net.corda.core.node.ZoneVersionTooLowException
|
||||
import net.corda.core.serialization.SerializationContext
|
||||
import net.corda.core.transactions.LedgerTransaction
|
||||
import net.corda.core.transactions.SignedTransaction
|
||||
import net.corda.core.transactions.TransactionBuilder
|
||||
import net.corda.core.transactions.WireTransaction
|
||||
import org.slf4j.MDC
|
||||
|
||||
// *Internal* Corda-specific utilities
|
||||
|
||||
fun ServicesForResolution.ensureMinimumPlatformVersion(requiredMinPlatformVersion: Int, feature: String) {
|
||||
val currentMinPlatformVersion = networkParameters.minimumPlatformVersion
|
||||
if (currentMinPlatformVersion < requiredMinPlatformVersion) {
|
||||
throw ZoneVersionTooLowException(
|
||||
"$feature requires all nodes on the Corda compatibility zone to be running at least platform version " +
|
||||
"$requiredMinPlatformVersion. The current zone is only enforcing a minimum platform version of " +
|
||||
"$currentMinPlatformVersion. Please contact your zone operator."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Provide access to internal method for AttachmentClassLoaderTests */
|
||||
@DeleteForDJVM
|
||||
fun TransactionBuilder.toWireTransaction(services: ServicesForResolution, serializationContext: SerializationContext): WireTransaction {
|
||||
return toWireTransactionWithContext(services, serializationContext)
|
||||
}
|
||||
|
||||
/** Provide access to internal method for AttachmentClassLoaderTests */
|
||||
@DeleteForDJVM
|
||||
fun TransactionBuilder.toLedgerTransaction(services: ServicesForResolution, serializationContext: SerializationContext): LedgerTransaction {
|
||||
return toLedgerTransactionWithContext(services, serializationContext)
|
||||
}
|
||||
|
||||
fun createCordappContext(cordapp: Cordapp, attachmentId: SecureHash?, classLoader: ClassLoader, config: CordappConfig): CordappContext {
|
||||
return CordappContext(cordapp, attachmentId, classLoader, config)
|
||||
}
|
||||
|
||||
/** Checks if this flow is an idempotent flow. */
|
||||
fun Class<out FlowLogic<*>>.isIdempotentFlow(): Boolean {
|
||||
return IdempotentFlow::class.java.isAssignableFrom(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures each log entry from the current thread will contain id of the transaction in the MDC.
|
||||
*/
|
||||
internal fun SignedTransaction.pushToLoggingContext() {
|
||||
MDC.put("tx_id", id.toString())
|
||||
}
|
@ -399,18 +399,6 @@ fun <T, U : T> uncheckedCast(obj: T) = obj as U
|
||||
|
||||
fun <K, V> Iterable<Pair<K, V>>.toMultiMap(): Map<K, List<V>> = this.groupBy({ it.first }) { it.second }
|
||||
|
||||
/** Provide access to internal method for AttachmentClassLoaderTests */
|
||||
@DeleteForDJVM
|
||||
fun TransactionBuilder.toWireTransaction(services: ServicesForResolution, serializationContext: SerializationContext): WireTransaction {
|
||||
return toWireTransactionWithContext(services, serializationContext)
|
||||
}
|
||||
|
||||
/** Provide access to internal method for AttachmentClassLoaderTests */
|
||||
@DeleteForDJVM
|
||||
fun TransactionBuilder.toLedgerTransaction(services: ServicesForResolution, serializationContext: SerializationContext): LedgerTransaction {
|
||||
return toLedgerTransactionWithContext(services, serializationContext)
|
||||
}
|
||||
|
||||
/** Returns the location of this class. */
|
||||
val Class<*>.location: URL get() = protectionDomain.codeSource.location
|
||||
|
||||
@ -510,29 +498,13 @@ fun <T : Any> SerializedBytes<T>.sign(keyPair: KeyPair): SignedData<T> = SignedD
|
||||
|
||||
fun ByteBuffer.copyBytes(): ByteArray = ByteArray(remaining()).also { get(it) }
|
||||
|
||||
fun createCordappContext(cordapp: Cordapp, attachmentId: SecureHash?, classLoader: ClassLoader, config: CordappConfig): CordappContext {
|
||||
return CordappContext(cordapp, attachmentId, classLoader, config)
|
||||
}
|
||||
|
||||
val PublicKey.hash: SecureHash get() = encoded.sha256()
|
||||
|
||||
/** Checks if this flow is an idempotent flow. */
|
||||
fun Class<out FlowLogic<*>>.isIdempotentFlow(): Boolean {
|
||||
return IdempotentFlow::class.java.isAssignableFrom(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension method for providing a sumBy method that processes and returns a Long
|
||||
*/
|
||||
fun <T> Iterable<T>.sumByLong(selector: (T) -> Long): Long = this.map { selector(it) }.sum()
|
||||
|
||||
/**
|
||||
* Ensures each log entry from the current thread will contain id of the transaction in the MDC.
|
||||
*/
|
||||
internal fun SignedTransaction.pushToLoggingContext() {
|
||||
MDC.put("tx_id", id.toString())
|
||||
}
|
||||
|
||||
fun <T : Any> SerializedBytes<Any>.checkPayloadIs(type: Class<T>): UntrustworthyData<T> {
|
||||
val payloadData: T = try {
|
||||
val serializer = SerializationDefaults.SERIALIZATION_FACTORY
|
||||
|
@ -10,6 +10,7 @@
|
||||
|
||||
package net.corda.core.node
|
||||
|
||||
import net.corda.core.CordaRuntimeException
|
||||
import net.corda.core.KeepForDJVM
|
||||
import net.corda.core.identity.Party
|
||||
import net.corda.core.node.services.AttachmentId
|
||||
@ -115,4 +116,10 @@ data class NetworkParameters(
|
||||
*/
|
||||
@KeepForDJVM
|
||||
@CordaSerializable
|
||||
data class NotaryInfo(val identity: Party, val validating: Boolean)
|
||||
data class NotaryInfo(val identity: Party, val validating: Boolean)
|
||||
|
||||
/**
|
||||
* When a Corda feature cannot be used due to the node's compatibility zone not enforcing a high enough minimum platform
|
||||
* version.
|
||||
*/
|
||||
class ZoneVersionTooLowException(message: String) : CordaRuntimeException(message)
|
||||
|
@ -11,6 +11,7 @@
|
||||
package net.corda.core.transactions
|
||||
|
||||
import co.paralleluniverse.strands.Strand
|
||||
import net.corda.core.CordaInternal
|
||||
import net.corda.core.DeleteForDJVM
|
||||
import net.corda.core.contracts.*
|
||||
import net.corda.core.cordapp.CordappProvider
|
||||
@ -19,9 +20,11 @@ import net.corda.core.crypto.SignableData
|
||||
import net.corda.core.crypto.SignatureMetadata
|
||||
import net.corda.core.identity.Party
|
||||
import net.corda.core.internal.FlowStateMachine
|
||||
import net.corda.core.internal.ensureMinimumPlatformVersion
|
||||
import net.corda.core.node.NetworkParameters
|
||||
import net.corda.core.node.ServiceHub
|
||||
import net.corda.core.node.ServicesForResolution
|
||||
import net.corda.core.node.ZoneVersionTooLowException
|
||||
import net.corda.core.node.services.AttachmentId
|
||||
import net.corda.core.node.services.KeyManagementService
|
||||
import net.corda.core.serialization.SerializationContext
|
||||
@ -84,7 +87,7 @@ open class TransactionBuilder @JvmOverloads constructor(
|
||||
for (t in items) {
|
||||
when (t) {
|
||||
is StateAndRef<*> -> addInputState(t)
|
||||
is ReferencedStateAndRef<*> -> @Suppress("DEPRECATION") addReferenceState(t) // Will remove when feature finalised.
|
||||
is ReferencedStateAndRef<*> -> addReferenceState(t)
|
||||
is SecureHash -> addAttachment(t)
|
||||
is TransactionState<*> -> addOutputState(t)
|
||||
is StateAndContract -> addOutputState(t.state, t.contract)
|
||||
@ -105,11 +108,18 @@ open class TransactionBuilder @JvmOverloads constructor(
|
||||
* [HashAttachmentConstraint].
|
||||
*
|
||||
* @returns A new [WireTransaction] that will be unaffected by further changes to this [TransactionBuilder].
|
||||
*
|
||||
* @throws ZoneVersionTooLowException if there are reference states and the zone minimum platform version is less than 4.
|
||||
*/
|
||||
@Throws(MissingContractAttachments::class)
|
||||
fun toWireTransaction(services: ServicesForResolution): WireTransaction = toWireTransactionWithContext(services)
|
||||
|
||||
@CordaInternal
|
||||
internal fun toWireTransactionWithContext(services: ServicesForResolution, serializationContext: SerializationContext? = null): WireTransaction {
|
||||
val referenceStates = referenceStates()
|
||||
if (referenceStates.isNotEmpty()) {
|
||||
services.ensureMinimumPlatformVersion(4, "Reference states")
|
||||
}
|
||||
|
||||
// Resolves the AutomaticHashConstraints to HashAttachmentConstraints or WhitelistedByZoneAttachmentConstraint based on a global parameter.
|
||||
// The AutomaticHashConstraint allows for less boiler plate when constructing transactions since for the typical case the named contract
|
||||
@ -119,14 +129,27 @@ open class TransactionBuilder @JvmOverloads constructor(
|
||||
when {
|
||||
state.constraint !== AutomaticHashConstraint -> state
|
||||
useWhitelistedByZoneAttachmentConstraint(state.contract, services.networkParameters) -> state.copy(constraint = WhitelistedByZoneAttachmentConstraint)
|
||||
else -> services.cordappProvider.getContractAttachmentID(state.contract)?.let {
|
||||
state.copy(constraint = HashAttachmentConstraint(it))
|
||||
} ?: throw MissingContractAttachments(listOf(state))
|
||||
else -> {
|
||||
services.cordappProvider.getContractAttachmentID(state.contract)?.let {
|
||||
state.copy(constraint = HashAttachmentConstraint(it))
|
||||
} ?: throw MissingContractAttachments(listOf(state))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return SerializationFactory.defaultFactory.withCurrentContext(serializationContext) {
|
||||
WireTransaction(WireTransaction.createComponentGroups(inputStates(), resolvedOutputs, commands, attachments + makeContractAttachments(services.cordappProvider), notary, window, referenceStates()), privacySalt)
|
||||
WireTransaction(
|
||||
WireTransaction.createComponentGroups(
|
||||
inputStates(),
|
||||
resolvedOutputs,
|
||||
commands,
|
||||
attachments + makeContractAttachments(services.cordappProvider),
|
||||
notary,
|
||||
window,
|
||||
referenceStates
|
||||
),
|
||||
privacySalt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -179,12 +202,9 @@ open class TransactionBuilder @JvmOverloads constructor(
|
||||
/**
|
||||
* Adds a reference input [StateRef] to the transaction.
|
||||
*
|
||||
* This feature was added in version 4 of Corda, so will throw an exception for any Corda networks with a minimum
|
||||
* platform version less than 4.
|
||||
*
|
||||
* @throws UncheckedVersionException
|
||||
* Note: Reference states are only supported on Corda networks running a minimum platform version of 4.
|
||||
* [toWireTransaction] will throw an [IllegalStateException] if called in such an environment.
|
||||
*/
|
||||
@Deprecated(message = "Feature not yet released. Pending stabilisation.")
|
||||
open fun addReferenceState(referencedStateAndRef: ReferencedStateAndRef<*>): TransactionBuilder {
|
||||
val stateAndRef = referencedStateAndRef.stateAndRef
|
||||
referencesWithTransactionState.add(stateAndRef.state)
|
||||
@ -293,10 +313,10 @@ open class TransactionBuilder @JvmOverloads constructor(
|
||||
return this
|
||||
}
|
||||
|
||||
/** Returns an immutable list of input [StateRefs]. */
|
||||
/** Returns an immutable list of input [StateRef]s. */
|
||||
fun inputStates(): List<StateRef> = ArrayList(inputs)
|
||||
|
||||
/** Returns an immutable list of reference input [StateRefs]. */
|
||||
/** Returns an immutable list of reference input [StateRef]s. */
|
||||
fun referenceStates(): List<StateRef> = ArrayList(references)
|
||||
|
||||
/** Returns an immutable list of attachment hashes. */
|
||||
@ -312,7 +332,10 @@ open class TransactionBuilder @JvmOverloads constructor(
|
||||
* Sign the built transaction and return it. This is an internal function for use by the service hub, please use
|
||||
* [ServiceHub.signInitialTransaction] instead.
|
||||
*/
|
||||
fun toSignedTransaction(keyManagementService: KeyManagementService, publicKey: PublicKey, signatureMetadata: SignatureMetadata, services: ServicesForResolution): SignedTransaction {
|
||||
fun toSignedTransaction(keyManagementService: KeyManagementService,
|
||||
publicKey: PublicKey,
|
||||
signatureMetadata: SignatureMetadata,
|
||||
services: ServicesForResolution): SignedTransaction {
|
||||
val wtx = toWireTransaction(services)
|
||||
val signableData = SignableData(wtx.id, signatureMetadata)
|
||||
val sig = keyManagementService.sign(signableData, publicKey)
|
||||
|
@ -15,8 +15,8 @@ import com.natpryce.hamkrest.*
|
||||
import com.natpryce.hamkrest.assertion.assert
|
||||
import net.corda.core.contracts.Attachment
|
||||
import net.corda.core.crypto.SecureHash
|
||||
import net.corda.core.flows.matchers.flow.willReturn
|
||||
import net.corda.core.flows.matchers.flow.willThrow
|
||||
import net.corda.testing.internal.matchers.flow.willReturn
|
||||
import net.corda.testing.internal.matchers.flow.willThrow
|
||||
import net.corda.core.flows.mixins.WithMockNet
|
||||
import net.corda.core.identity.CordaX500Name
|
||||
import net.corda.core.identity.Party
|
||||
@ -26,6 +26,7 @@ import net.corda.core.internal.hash
|
||||
import net.corda.node.services.persistence.NodeAttachmentService
|
||||
import net.corda.testing.core.ALICE_NAME
|
||||
import net.corda.testing.core.BOB_NAME
|
||||
import net.corda.testing.core.makeUnique
|
||||
import net.corda.testing.core.singleIdentity
|
||||
import net.corda.testing.node.internal.InternalMockNetwork
|
||||
import net.corda.testing.node.internal.InternalMockNodeParameters
|
||||
@ -130,13 +131,13 @@ class AttachmentTests : WithMockNet {
|
||||
|
||||
//region Generators
|
||||
override fun makeNode(name: CordaX500Name) =
|
||||
mockNet.createPartyNode(randomise(name)).apply {
|
||||
mockNet.createPartyNode(makeUnique(name)).apply {
|
||||
registerInitiatedFlow(FetchAttachmentsResponse::class.java)
|
||||
}
|
||||
|
||||
// Makes a node that doesn't do sanity checking at load time.
|
||||
private fun makeBadNode(name: CordaX500Name) = mockNet.createNode(
|
||||
InternalMockNodeParameters(legalName = randomise(name)),
|
||||
InternalMockNodeParameters(legalName = makeUnique(name)),
|
||||
nodeFactory = { args, _ ->
|
||||
object : InternalMockNetwork.MockNode(args) {
|
||||
override fun start() = super.start().apply { attachments.checkAttachmentsOnLoad = false }
|
||||
|
@ -15,8 +15,8 @@ import com.natpryce.hamkrest.assertion.assert
|
||||
import net.corda.core.contracts.Command
|
||||
import net.corda.core.contracts.StateAndContract
|
||||
import net.corda.core.contracts.requireThat
|
||||
import net.corda.core.flows.matchers.flow.willReturn
|
||||
import net.corda.core.flows.matchers.flow.willThrow
|
||||
import net.corda.testing.internal.matchers.flow.willReturn
|
||||
import net.corda.testing.internal.matchers.flow.willThrow
|
||||
import net.corda.core.flows.mixins.WithContracts
|
||||
import net.corda.core.identity.CordaX500Name
|
||||
import net.corda.core.identity.Party
|
||||
|
@ -8,8 +8,8 @@ import com.natpryce.hamkrest.isA
|
||||
import net.corda.core.CordaRuntimeException
|
||||
import net.corda.core.contracts.ContractState
|
||||
import net.corda.core.contracts.StateAndRef
|
||||
import net.corda.core.flows.matchers.rpc.willReturn
|
||||
import net.corda.core.flows.matchers.rpc.willThrow
|
||||
import net.corda.testing.internal.matchers.rpc.willReturn
|
||||
import net.corda.testing.internal.matchers.rpc.willThrow
|
||||
import net.corda.core.flows.mixins.WithContracts
|
||||
import net.corda.core.flows.mixins.WithFinality
|
||||
import net.corda.core.messaging.CordaRPCOps
|
||||
|
@ -13,8 +13,8 @@ package net.corda.core.flows
|
||||
import com.natpryce.hamkrest.*
|
||||
import com.natpryce.hamkrest.assertion.assert
|
||||
import net.corda.core.contracts.*
|
||||
import net.corda.core.flows.matchers.flow.willReturn
|
||||
import net.corda.core.flows.matchers.flow.willThrow
|
||||
import net.corda.testing.internal.matchers.flow.willReturn
|
||||
import net.corda.testing.internal.matchers.flow.willThrow
|
||||
import net.corda.core.flows.mixins.WithContracts
|
||||
import net.corda.core.flows.mixins.WithFinality
|
||||
import net.corda.core.identity.AbstractParty
|
||||
@ -90,9 +90,9 @@ class ContractUpgradeFlowTest : WithContracts, WithFinality {
|
||||
// Party A initiates contract upgrade flow, expected to succeed this time.
|
||||
assert.that(
|
||||
aliceNode.initiateDummyContractUpgrade(atx),
|
||||
willReturn(
|
||||
aliceNode.hasDummyContractUpgradeTransaction()
|
||||
and bobNode.hasDummyContractUpgradeTransaction()))
|
||||
willReturn(
|
||||
aliceNode.hasDummyContractUpgradeTransaction()
|
||||
and bobNode.hasDummyContractUpgradeTransaction()))
|
||||
}
|
||||
|
||||
private fun TestStartedNode.issueCash(amount: Amount<Currency> = Amount(1000, USD)) =
|
||||
|
@ -12,8 +12,8 @@ package net.corda.core.flows
|
||||
|
||||
import com.natpryce.hamkrest.and
|
||||
import com.natpryce.hamkrest.assertion.assert
|
||||
import net.corda.core.flows.matchers.flow.willReturn
|
||||
import net.corda.core.flows.matchers.flow.willThrow
|
||||
import net.corda.testing.internal.matchers.flow.willReturn
|
||||
import net.corda.testing.internal.matchers.flow.willThrow
|
||||
import net.corda.core.flows.mixins.WithFinality
|
||||
import net.corda.core.identity.Party
|
||||
import net.corda.core.transactions.SignedTransaction
|
||||
|
@ -12,7 +12,7 @@ package net.corda.core.flows
|
||||
|
||||
import co.paralleluniverse.fibers.Suspendable
|
||||
import com.natpryce.hamkrest.assertion.assert
|
||||
import net.corda.core.flows.matchers.flow.willReturn
|
||||
import net.corda.testing.internal.matchers.flow.willReturn
|
||||
import net.corda.core.flows.mixins.WithMockNet
|
||||
import net.corda.core.identity.Party
|
||||
import net.corda.core.utilities.UntrustworthyData
|
||||
|
@ -1,36 +0,0 @@
|
||||
package net.corda.core.flows.matchers.flow
|
||||
|
||||
import com.natpryce.hamkrest.Matcher
|
||||
import com.natpryce.hamkrest.equalTo
|
||||
import com.natpryce.hamkrest.has
|
||||
import net.corda.core.flows.matchers.willReturn
|
||||
import net.corda.core.flows.matchers.willThrow
|
||||
import net.corda.core.internal.FlowStateMachine
|
||||
|
||||
/**
|
||||
* Matches a Flow that succeeds with a result matched by the given matcher
|
||||
*/
|
||||
fun <T> willReturn() = has(FlowStateMachine<T>::resultFuture, willReturn())
|
||||
|
||||
fun <T> willReturn(expected: T): Matcher<FlowStateMachine<out T?>> = net.corda.core.flows.matchers.flow.willReturn(equalTo(expected))
|
||||
|
||||
/**
|
||||
* Matches a Flow that succeeds with a result matched by the given matcher
|
||||
*/
|
||||
fun <T> willReturn(successMatcher: Matcher<T>) = has(
|
||||
FlowStateMachine<out T>::resultFuture,
|
||||
willReturn(successMatcher))
|
||||
|
||||
/**
|
||||
* Matches a Flow that fails, with an exception matched by the given matcher.
|
||||
*/
|
||||
inline fun <reified E: Exception> willThrow(failureMatcher: Matcher<E>) = has(
|
||||
FlowStateMachine<*>::resultFuture,
|
||||
willThrow(failureMatcher))
|
||||
|
||||
/**
|
||||
* Matches a Flow that fails, with an exception of the specified type.
|
||||
*/
|
||||
inline fun <reified E: Exception> willThrow() = has(
|
||||
FlowStateMachine<*>::resultFuture,
|
||||
willThrow<E>())
|
@ -1,31 +0,0 @@
|
||||
package net.corda.core.flows.matchers.rpc
|
||||
|
||||
import com.natpryce.hamkrest.Matcher
|
||||
import com.natpryce.hamkrest.has
|
||||
import net.corda.core.flows.matchers.willReturn
|
||||
import net.corda.core.flows.matchers.willThrow
|
||||
import net.corda.core.messaging.FlowHandle
|
||||
|
||||
/**
|
||||
* Matches a flow handle that succeeds with a result matched by the given matcher
|
||||
*/
|
||||
fun <T> willReturn() = has(FlowHandle<T>::returnValue, willReturn())
|
||||
|
||||
/**
|
||||
* Matches a flow handle that succeeds with a result matched by the given matcher
|
||||
*/
|
||||
fun <T> willReturn(successMatcher: Matcher<T>) = has(FlowHandle<out T>::returnValue, willReturn(successMatcher))
|
||||
|
||||
/**
|
||||
* Matches a flow handle that fails, with an exception matched by the given matcher.
|
||||
*/
|
||||
inline fun <reified E: Exception> willThrow(failureMatcher: Matcher<E>) = has(
|
||||
FlowHandle<*>::returnValue,
|
||||
willThrow(failureMatcher))
|
||||
|
||||
/**
|
||||
* Matches a flow handle that fails, with an exception of the specified type.
|
||||
*/
|
||||
inline fun <reified E: Exception> willThrow() = has(
|
||||
FlowHandle<*>::returnValue,
|
||||
willThrow<E>())
|
@ -9,10 +9,10 @@ import net.corda.core.identity.PartyAndCertificate
|
||||
import net.corda.core.internal.FlowStateMachine
|
||||
import net.corda.core.transactions.SignedTransaction
|
||||
import net.corda.core.transactions.TransactionBuilder
|
||||
import net.corda.testing.core.makeUnique
|
||||
import net.corda.testing.node.internal.InternalMockNetwork
|
||||
import net.corda.testing.node.internal.TestStartedNode
|
||||
import net.corda.testing.node.internal.startFlow
|
||||
import java.util.*
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/**
|
||||
@ -25,12 +25,7 @@ interface WithMockNet {
|
||||
/**
|
||||
* Create a node using a randomised version of the given name
|
||||
*/
|
||||
fun makeNode(name: CordaX500Name) = mockNet.createPartyNode(randomise(name))
|
||||
|
||||
/**
|
||||
* Randomise a party name to avoid clashes with other tests
|
||||
*/
|
||||
fun randomise(name: CordaX500Name) = name.copy(commonName = "${name.commonName}_${UUID.randomUUID()}")
|
||||
fun makeNode(name: CordaX500Name) = mockNet.createPartyNode(makeUnique(name))
|
||||
|
||||
/**
|
||||
* Run the mock network before proceeding
|
||||
|
@ -0,0 +1,85 @@
|
||||
package net.corda.core.transactions
|
||||
|
||||
import com.nhaarman.mockito_kotlin.doReturn
|
||||
import com.nhaarman.mockito_kotlin.whenever
|
||||
import net.corda.core.contracts.*
|
||||
import net.corda.core.cordapp.CordappProvider
|
||||
import net.corda.core.crypto.SecureHash
|
||||
import net.corda.core.node.ServicesForResolution
|
||||
import net.corda.core.node.ZoneVersionTooLowException
|
||||
import net.corda.testing.common.internal.testNetworkParameters
|
||||
import net.corda.testing.contracts.DummyContract
|
||||
import net.corda.testing.contracts.DummyState
|
||||
import net.corda.testing.core.DUMMY_NOTARY_NAME
|
||||
import net.corda.testing.core.DummyCommandData
|
||||
import net.corda.testing.core.SerializationEnvironmentRule
|
||||
import net.corda.testing.core.TestIdentity
|
||||
import net.corda.testing.internal.rigorousMock
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.assertj.core.api.Assertions.assertThatThrownBy
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class TransactionBuilderTest {
|
||||
@Rule
|
||||
@JvmField
|
||||
val testSerialization = SerializationEnvironmentRule()
|
||||
|
||||
private val notary = TestIdentity(DUMMY_NOTARY_NAME).party
|
||||
private val services = rigorousMock<ServicesForResolution>()
|
||||
private val contractAttachmentId = SecureHash.randomSHA256()
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
val cordappProvider = rigorousMock<CordappProvider>()
|
||||
doReturn(cordappProvider).whenever(services).cordappProvider
|
||||
doReturn(contractAttachmentId).whenever(cordappProvider).getContractAttachmentID(DummyContract.PROGRAM_ID)
|
||||
doReturn(testNetworkParameters()).whenever(services).networkParameters
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bare minimum issuance tx`() {
|
||||
val outputState = TransactionState(
|
||||
data = DummyState(),
|
||||
contract = DummyContract.PROGRAM_ID,
|
||||
notary = notary,
|
||||
constraint = HashAttachmentConstraint(contractAttachmentId)
|
||||
)
|
||||
val builder = TransactionBuilder()
|
||||
.addOutputState(outputState)
|
||||
.addCommand(DummyCommandData, notary.owningKey)
|
||||
val wtx = builder.toWireTransaction(services)
|
||||
assertThat(wtx.outputs).containsOnly(outputState)
|
||||
assertThat(wtx.commands).containsOnly(Command(DummyCommandData, notary.owningKey))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `automatic hash constraint`() {
|
||||
val outputState = TransactionState(data = DummyState(), contract = DummyContract.PROGRAM_ID, notary = notary)
|
||||
val builder = TransactionBuilder()
|
||||
.addOutputState(outputState)
|
||||
.addCommand(DummyCommandData, notary.owningKey)
|
||||
val wtx = builder.toWireTransaction(services)
|
||||
assertThat(wtx.outputs).containsOnly(outputState.copy(constraint = HashAttachmentConstraint(contractAttachmentId)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reference states`() {
|
||||
val referenceState = TransactionState(DummyState(), DummyContract.PROGRAM_ID, notary)
|
||||
val referenceStateRef = StateRef(SecureHash.randomSHA256(), 1)
|
||||
val builder = TransactionBuilder(notary)
|
||||
.addReferenceState(StateAndRef(referenceState, referenceStateRef).referenced())
|
||||
.addOutputState(TransactionState(DummyState(), DummyContract.PROGRAM_ID, notary))
|
||||
.addCommand(DummyCommandData, notary.owningKey)
|
||||
|
||||
doReturn(testNetworkParameters(minimumPlatformVersion = 3)).whenever(services).networkParameters
|
||||
assertThatThrownBy { builder.toWireTransaction(services) }
|
||||
.isInstanceOf(ZoneVersionTooLowException::class.java)
|
||||
.hasMessageContaining("Reference states")
|
||||
|
||||
doReturn(testNetworkParameters(minimumPlatformVersion = 4)).whenever(services).networkParameters
|
||||
val wtx = builder.toWireTransaction(services)
|
||||
assertThat(wtx.references).containsOnly(referenceStateRef)
|
||||
}
|
||||
}
|
@ -6,8 +6,13 @@ release, see :doc:`upgrade-notes`.
|
||||
|
||||
Unreleased
|
||||
----------
|
||||
* Vault query fix: support query by parent classes of Contract State classes (see https://github.com/corda/corda/issues/3714)
|
||||
|
||||
* Added ``registerResponderFlow`` method to ``StartedMockNode``, to support isolated testing of responder flow behaviour.
|
||||
|
||||
* "app", "rpc", "p2p" and "unknown" are no longer allowed as uploader values when importing attachments. These are used
|
||||
internally in security sensitive code.
|
||||
|
||||
* Introduced ``TestCorDapp`` and utilities to support asymmetric setups for nodes through ``DriverDSL``, ``MockNetwork`` and ``MockServices``.
|
||||
|
||||
* Change type of the `checkpoint_value` column. Please check the upgrade-notes on how to update your database.
|
||||
@ -195,7 +200,8 @@ Unreleased
|
||||
to in a transaction by the contracts of input and output states but whose contract is not executed as part of the
|
||||
transaction verification process and is not consumed when the transaction is committed to the ledger but is checked
|
||||
for "current-ness". In other words, the contract logic isn't run for the referencing transaction only. It's still a
|
||||
normal state when it occurs in an input or output position.
|
||||
normal state when it occurs in an input or output position. *This feature is only available on Corda networks running
|
||||
with a minimum platform version of 4.*
|
||||
|
||||
.. _changelog_v3.1:
|
||||
|
||||
|
@ -105,7 +105,7 @@ absolute path to the node's base directory.
|
||||
|
||||
:h2Port: Deprecated. Use ``h2Settings`` instead.
|
||||
|
||||
:h2Settings: Sets the H2 JDBC server port. See :doc:`node-database-access-h2`.
|
||||
:h2Settings: Sets the H2 JDBC server host and port. See :doc:`node-database-access-h2`. For non-localhost address the database passowrd needs to be set in ``dataSourceProperties``.
|
||||
|
||||
:messagingServerAddress: The address of the ArtemisMQ broker instance. If not provided the node will run one locally.
|
||||
|
||||
|
@ -99,6 +99,7 @@ task deployNodes(type: net.corda.plugins.Cordform, dependsOn: ['jar']) {
|
||||
adminAddress "localhost:10013"
|
||||
}
|
||||
webPort 10004
|
||||
extraConfig = ['h2Settings.address' : 'localhost:10014']
|
||||
cordapps = []
|
||||
}
|
||||
node {
|
||||
@ -109,6 +110,7 @@ task deployNodes(type: net.corda.plugins.Cordform, dependsOn: ['jar']) {
|
||||
adminAddress "localhost:10016"
|
||||
}
|
||||
webPort 10007
|
||||
extraConfig = ['h2Settings.address' : 'localhost:10017']
|
||||
cordapps = []
|
||||
rpcUsers = [
|
||||
['username' : "user",
|
||||
|
@ -52,7 +52,6 @@ class CustomVaultQueryTest {
|
||||
mockNet = MockNetwork(threadPerNode = true, cordappPackages = listOf("net.corda.finance", "net.corda.docs", "com.template"))
|
||||
nodeA = mockNet.createPartyNode()
|
||||
nodeB = mockNet.createPartyNode()
|
||||
nodeA.registerInitiatedFlow(TopupIssuerFlow.TopupIssuer::class.java)
|
||||
notary = mockNet.defaultNotaryIdentity
|
||||
}
|
||||
|
||||
|
@ -46,7 +46,6 @@ class WorkflowTransactionBuildTutorialTest {
|
||||
mockNet = MockNetwork(threadPerNode = true, cordappPackages = listOf("net.corda.docs"))
|
||||
aliceNode = mockNet.createPartyNode(ALICE_NAME)
|
||||
bobNode = mockNet.createPartyNode(BOB_NAME)
|
||||
aliceNode.registerInitiatedFlow(RecordCompletionFlow::class.java)
|
||||
alice = aliceNode.services.myInfo.identityFromX500Name(ALICE_NAME)
|
||||
bob = bobNode.services.myInfo.identityFromX500Name(BOB_NAME)
|
||||
}
|
||||
|
@ -70,7 +70,7 @@ the three node folders. Each node folder has the following structure:
|
||||
|
||||
.
|
||||
|____corda.jar // The runnable node
|
||||
|____corda-webserver.jar // The node's webserver
|
||||
|____corda-webserver.jar // The node's webserver (The notary doesn't need a web server)
|
||||
|____node.conf // The node's configuration file
|
||||
|____cordapps
|
||||
|____java/kotlin-source-0.1.jar // Our IOU CorDapp
|
||||
@ -85,7 +85,7 @@ Let's start the nodes by running the following commands from the root of the pro
|
||||
// On Mac
|
||||
build/nodes/runnodes
|
||||
|
||||
This will start a terminal window for each node, and an additional terminal window for each node's webserver - eight
|
||||
This will start a terminal window for each node, and an additional terminal window for each node's webserver - five
|
||||
terminal windows in all. Give each node a moment to start - you'll know it's ready when its terminal windows displays
|
||||
the message, "Welcome to the Corda interactive shell.".
|
||||
|
||||
|
@ -32,13 +32,17 @@ If you want H2 to auto-select a port (mimicking the old ``h2Port`` behaviour), y
|
||||
address: "localhost:0"
|
||||
}
|
||||
|
||||
If remote access is required, the address can be changed to ``0.0.0.0``. However it is recommended to change the default username and password before doing so.
|
||||
If remote access is required, the address can be changed to ``0.0.0.0``.
|
||||
The node requires a database password to be set when the database is exposed on the network interface to listen on.
|
||||
|
||||
.. sourcecode:: groovy
|
||||
|
||||
h2Settings {
|
||||
address: "0.0.0.0:12345"
|
||||
}
|
||||
dataSourceProperties {
|
||||
dataSource.password : "strongpassword"
|
||||
}
|
||||
|
||||
The previous ``h2Port`` syntax is now deprecated. ``h2Port`` will continue to work but the database
|
||||
will only be accessible on localhost.
|
||||
|
@ -3,7 +3,24 @@ Node database
|
||||
|
||||
Default in-memory database
|
||||
--------------------------
|
||||
By default, nodes store their data in an H2 database. You can connect directly to a running node's database to see its
|
||||
By default, nodes store their data in an H2 database.
|
||||
The database (a file persistence.mv.db) is created at the first node startup with the administrator user 'sa' and a blank password.
|
||||
The user name and password can be changed in node configuration:
|
||||
|
||||
.. sourcecode:: groovy
|
||||
|
||||
dataSourceProperties = {
|
||||
dataSource.user = [USER]
|
||||
dataSource.password = [PASSWORD]
|
||||
}
|
||||
|
||||
Note, changing user/password for the existing node in node.conf will not update them in the H2 database,
|
||||
you need to login to the database first to create new user or change the user password.
|
||||
The database password is required only when the H2 database is exposed on non-localhost address (which is disabled by default).
|
||||
The node requires the user with administrator permissions in order to creates tables upon the first startup
|
||||
or after deplying new CorDapps with own tables.
|
||||
|
||||
You can connect directly to a running node's database to see its
|
||||
stored states, transactions and attachments as follows:
|
||||
|
||||
* Enable the H2 database access in the node configuration using the following syntax:
|
||||
@ -35,7 +52,8 @@ interface for you to query them using SQL.
|
||||
|
||||
The default behaviour is to expose the H2 database on localhost. This can be overridden in the
|
||||
node configuration using ``h2Settings.address`` and specifying the address of the network interface to listen on,
|
||||
or simply using ``0.0.0.0:0`` to listen on all interfaces.
|
||||
or simply using ``0.0.0.0:0`` to listen on all interfaces. The node requires a database password to be set when
|
||||
the database is exposed on the network interface to listen on.
|
||||
|
||||
.. _standalone_database_config_examples_ref:
|
||||
|
||||
|
@ -42,7 +42,7 @@ class AddressBindingFailureTests: IntegrationTest() {
|
||||
@Test
|
||||
fun `H2 address`() {
|
||||
assumeTrue(!IntegrationTest.isRemoteDatabaseMode()) // Enterprise only - disable test where running against remote database
|
||||
assertBindExceptionForOverrides { address -> mapOf("h2Settings" to mapOf("address" to address.toString())) }
|
||||
assertBindExceptionForOverrides { address -> mapOf("h2Settings" to mapOf("address" to address.toString()), "dataSourceProperties.dataSource.password" to "password") }
|
||||
}
|
||||
|
||||
private fun assertBindExceptionForOverrides(overrides: (NetworkHostAndPort) -> Map<String, Any?>) {
|
||||
|
@ -0,0 +1,136 @@
|
||||
package net.corda.node.persistence
|
||||
|
||||
import co.paralleluniverse.fibers.Suspendable
|
||||
import net.corda.client.rpc.CordaRPCClient
|
||||
import net.corda.core.flows.FlowLogic
|
||||
import net.corda.core.flows.StartableByRPC
|
||||
import net.corda.core.messaging.startFlow
|
||||
import net.corda.core.utilities.getOrThrow
|
||||
import net.corda.node.services.Permissions
|
||||
import net.corda.nodeapi.internal.persistence.CouldNotCreateDataSourceException
|
||||
import net.corda.testing.driver.DriverParameters
|
||||
import net.corda.testing.driver.PortAllocation
|
||||
import net.corda.testing.driver.driver
|
||||
import net.corda.testing.node.User
|
||||
import org.junit.Test
|
||||
import java.net.InetAddress
|
||||
import java.sql.DriverManager
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class H2SecurityTests {
|
||||
companion object {
|
||||
private val port = PortAllocation.Incremental(21_000)
|
||||
private fun getFreePort() = port.nextPort()
|
||||
private const val h2AddressKey = "h2Settings.address"
|
||||
private const val dbPasswordKey = "dataSourceProperties.dataSource.password"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `h2 server starts when h2Settings are set`() {
|
||||
driver(DriverParameters(inMemoryDB = false, startNodesInProcess = isQuasarAgentSpecified(), notarySpecs = emptyList())) {
|
||||
val port = getFreePort()
|
||||
startNode(customOverrides = mapOf(h2AddressKey to "localhost:$port")).getOrThrow()
|
||||
DriverManager.getConnection("jdbc:h2:tcp://localhost:$port/node", "sa", "").use {
|
||||
assertTrue(it.createStatement().executeQuery("SELECT 1").next())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `h2 server on the host name requires non-default database password`() {
|
||||
driver(DriverParameters(inMemoryDB = false, startNodesInProcess = isQuasarAgentSpecified(), notarySpecs = emptyList())) {
|
||||
assertFailsWith(CouldNotCreateDataSourceException::class) {
|
||||
startNode(customOverrides = mapOf(h2AddressKey to "${InetAddress.getLocalHost().hostName}:${getFreePort()}")).getOrThrow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `h2 server on the external host IP requires non-default database password`() {
|
||||
driver(DriverParameters(inMemoryDB = false, startNodesInProcess = isQuasarAgentSpecified(), notarySpecs = emptyList())) {
|
||||
assertFailsWith(CouldNotCreateDataSourceException::class) {
|
||||
startNode(customOverrides = mapOf(h2AddressKey to "${InetAddress.getLocalHost().hostAddress}:${getFreePort()}")).getOrThrow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `h2 server on host name requires non-blank database password`() {
|
||||
driver(DriverParameters(inMemoryDB = false, startNodesInProcess = isQuasarAgentSpecified(), notarySpecs = emptyList())) {
|
||||
assertFailsWith(CouldNotCreateDataSourceException::class) {
|
||||
startNode(customOverrides = mapOf(h2AddressKey to "${InetAddress.getLocalHost().hostName}:${getFreePort()}",
|
||||
dbPasswordKey to " ")).getOrThrow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `h2 server on external host IP requires non-blank database password`() {
|
||||
driver(DriverParameters(inMemoryDB = false, startNodesInProcess = isQuasarAgentSpecified(), notarySpecs = emptyList())) {
|
||||
assertFailsWith(CouldNotCreateDataSourceException::class) {
|
||||
startNode(customOverrides = mapOf(h2AddressKey to "${InetAddress.getLocalHost().hostAddress}:${getFreePort()}",
|
||||
dbPasswordKey to " ")).getOrThrow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `h2 server on localhost runs with the default database password`() {
|
||||
driver(DriverParameters(inMemoryDB = false, startNodesInProcess = false, notarySpecs = emptyList())) {
|
||||
startNode(customOverrides = mapOf(h2AddressKey to "localhost:${getFreePort()}")).getOrThrow()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `h2 server to loopback IP runs with the default database password`() {
|
||||
driver(DriverParameters(inMemoryDB = false, startNodesInProcess = isQuasarAgentSpecified(), notarySpecs = emptyList())) {
|
||||
startNode(customOverrides = mapOf(h2AddressKey to "127.0.0.1:${getFreePort()}")).getOrThrow()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remote code execution via h2 server is disabled`() {
|
||||
driver(DriverParameters(inMemoryDB = false, startNodesInProcess = false, notarySpecs = emptyList())) {
|
||||
val port = getFreePort()
|
||||
startNode(customOverrides = mapOf(h2AddressKey to "localhost:$port", dbPasswordKey to "x")).getOrThrow()
|
||||
DriverManager.getConnection("jdbc:h2:tcp://localhost:$port/node", "sa", "x").use {
|
||||
assertFailsWith(org.h2.jdbc.JdbcSQLException::class) {
|
||||
it.createStatement().execute("CREATE ALIAS SET_PROPERTY FOR \"java.lang.System.setProperty\"")
|
||||
it.createStatement().execute("CALL SET_PROPERTY('abc', '1')")
|
||||
}
|
||||
}
|
||||
assertNull(System.getProperty("abc"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malicious flow tries to enable remote code execution via h2 server`() {
|
||||
val user = User("mark", "dadada", setOf(Permissions.startFlow<MaliciousFlow>()))
|
||||
driver(DriverParameters(inMemoryDB = false, startNodesInProcess = false, notarySpecs = emptyList())) {
|
||||
val port = getFreePort()
|
||||
val nodeHandle = startNode(rpcUsers = listOf(user), customOverrides = mapOf(h2AddressKey to "localhost:$port",
|
||||
dbPasswordKey to "x")).getOrThrow()
|
||||
CordaRPCClient(nodeHandle.rpcAddress).start(user.username, user.password).use {
|
||||
it.proxy.startFlow(::MaliciousFlow).returnValue.getOrThrow()
|
||||
}
|
||||
DriverManager.getConnection("jdbc:h2:tcp://localhost:$port/node", "sa", "x").use {
|
||||
assertFailsWith(org.h2.jdbc.JdbcSQLException::class) {
|
||||
it.createStatement().execute("CREATE ALIAS SET_PROPERTY FOR \"java.lang.System.setProperty\"")
|
||||
it.createStatement().execute("CALL SET_PROPERTY('abc', '1')")
|
||||
}
|
||||
}
|
||||
assertNull(System.getProperty("abc"))
|
||||
}
|
||||
}
|
||||
|
||||
@StartableByRPC
|
||||
class MaliciousFlow : FlowLogic<Boolean>() {
|
||||
@Suspendable
|
||||
override fun call(): Boolean {
|
||||
System.clearProperty("h2.allowedClasses")
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
@ -202,7 +202,6 @@ abstract class MQSecurityTest : NodeBasedTest() {
|
||||
|
||||
protected fun startBobAndCommunicateWithAlice(): Party {
|
||||
val bob = startNode(BOB_NAME)
|
||||
bob.registerInitiatedFlow(ReceiveFlow::class.java)
|
||||
val bobParty = bob.info.singleIdentity()
|
||||
// Perform a protocol exchange to force the peer queue to be created
|
||||
alice.services.startFlow(SendFlow(bobParty, 0)).resultFuture.getOrThrow()
|
||||
|
@ -230,7 +230,7 @@ abstract class AbstractNode<S>(val configuration: NodeConfiguration,
|
||||
private var _started: S? = null
|
||||
|
||||
private fun <T : Any> T.tokenize(): T {
|
||||
tokenizableServices?.add(this) ?: throw IllegalStateException("The tokenisable services list has already been finialised")
|
||||
tokenizableServices?.add(this) ?: throw IllegalStateException("The tokenisable services list has already been finalised")
|
||||
return this
|
||||
}
|
||||
|
||||
@ -254,10 +254,7 @@ abstract class AbstractNode<S>(val configuration: NodeConfiguration,
|
||||
|
||||
private fun initKeyStore(): X509Certificate {
|
||||
if (configuration.devMode) {
|
||||
log.warn("The Corda node is running in developer mode. This is not suitable for production usage.")
|
||||
configuration.configureWithDevSSLCertificate()
|
||||
} else {
|
||||
log.info("The Corda node is running in production mode. If this is a developer environment you can set 'devMode=true' in the node.conf file.")
|
||||
}
|
||||
return validateKeyStore()
|
||||
}
|
||||
@ -347,12 +344,12 @@ abstract class AbstractNode<S>(val configuration: NodeConfiguration,
|
||||
}
|
||||
|
||||
val (nodeInfo, signedNodeInfo) = nodeInfoAndSigned
|
||||
services.start(nodeInfo, netParams)
|
||||
networkMapUpdater.start(trustRoot, signedNetParams.raw.hash, signedNodeInfo.raw.hash)
|
||||
startMessagingService(rpcOps, nodeInfo, myNotaryIdentity, netParams)
|
||||
|
||||
// Do all of this in a database transaction so anything that might need a connection has one.
|
||||
return database.transaction {
|
||||
services.start(nodeInfo, netParams)
|
||||
identityService.loadIdentities(nodeInfo.legalIdentitiesAndCerts)
|
||||
attachments.start()
|
||||
cordappProvider.start(netParams.whitelistedContractImplementations)
|
||||
@ -692,6 +689,9 @@ abstract class AbstractNode<S>(val configuration: NodeConfiguration,
|
||||
} else {
|
||||
Observable.empty()
|
||||
}
|
||||
check(initiatingFlowClass !in flowFactories.keys) {
|
||||
"$initiatingFlowClass is attempting to register multiple initiated flows"
|
||||
}
|
||||
flowFactories[initiatingFlowClass] = flowFactory
|
||||
return observable
|
||||
}
|
||||
@ -772,7 +772,7 @@ abstract class AbstractNode<S>(val configuration: NodeConfiguration,
|
||||
}
|
||||
val props = configuration.dataSourceProperties
|
||||
if (props.isEmpty) throw DatabaseConfigurationException("There must be a database configured.")
|
||||
database.hikariStart(props, configuration.database, schemaService)
|
||||
database.startHikariPool(props, configuration.database, schemaService)
|
||||
// Now log the vendor string as this will also cause a connection to be tested eagerly.
|
||||
logVendorString(database, log)
|
||||
}
|
||||
@ -941,7 +941,7 @@ abstract class AbstractNode<S>(val configuration: NodeConfiguration,
|
||||
override val transactionVerifierService: TransactionVerifierService get() = this@AbstractNode.transactionVerifierService
|
||||
override val contractUpgradeService: ContractUpgradeService get() = this@AbstractNode.contractUpgradeService
|
||||
override val auditService: AuditService get() = this@AbstractNode.auditService
|
||||
override val attachments: AttachmentStorage get() = this@AbstractNode.attachments
|
||||
override val attachments: AttachmentStorageInternal get() = this@AbstractNode.attachments
|
||||
override val networkService: MessagingService get() = network
|
||||
override val clock: Clock get() = platformClock
|
||||
override val configuration: NodeConfiguration get() = this@AbstractNode.configuration
|
||||
@ -1038,7 +1038,7 @@ fun configureDatabase(hikariProperties: Properties,
|
||||
wellKnownPartyFromAnonymous: (AbstractParty) -> Party?,
|
||||
schemaService: SchemaService = NodeSchemaService()): CordaPersistence =
|
||||
createCordaPersistence(databaseConfig, wellKnownPartyFromX500Name, wellKnownPartyFromAnonymous, schemaService)
|
||||
.apply { hikariStart(hikariProperties, databaseConfig, schemaService) }
|
||||
.apply { startHikariPool(hikariProperties, databaseConfig, schemaService) }
|
||||
|
||||
fun createCordaPersistence(databaseConfig: DatabaseConfig,
|
||||
wellKnownPartyFromX500Name: (CordaX500Name) -> Party?,
|
||||
@ -1053,7 +1053,7 @@ fun createCordaPersistence(databaseConfig: DatabaseConfig,
|
||||
return CordaPersistence(databaseConfig, schemaService.schemaOptions.keys, attributeConverters)
|
||||
}
|
||||
|
||||
fun CordaPersistence.hikariStart(hikariProperties: Properties, databaseConfig: DatabaseConfig, schemaService: SchemaService) {
|
||||
fun CordaPersistence.startHikariPool(hikariProperties: Properties, databaseConfig: DatabaseConfig, schemaService: SchemaService) {
|
||||
try {
|
||||
val dataSource = DataSourceFactory.createDataSource(hikariProperties)
|
||||
val jdbcUrl = hikariProperties.getProperty("dataSource.url", "")
|
||||
|
@ -28,26 +28,12 @@ import net.corda.core.internal.FlowStateMachine
|
||||
import net.corda.core.internal.RPC_UPLOADER
|
||||
import net.corda.core.internal.STRUCTURAL_STEP_PREFIX
|
||||
import net.corda.core.internal.sign
|
||||
import net.corda.core.messaging.CordaRPCOps
|
||||
import net.corda.core.messaging.DataFeed
|
||||
import net.corda.core.messaging.FlowHandle
|
||||
import net.corda.core.messaging.FlowHandleImpl
|
||||
import net.corda.core.messaging.FlowProgressHandle
|
||||
import net.corda.core.messaging.FlowProgressHandleImpl
|
||||
import net.corda.core.messaging.ParametersUpdateInfo
|
||||
import net.corda.core.messaging.RPCReturnsObservables
|
||||
import net.corda.core.messaging.StateMachineInfo
|
||||
import net.corda.core.messaging.StateMachineTransactionMapping
|
||||
import net.corda.core.messaging.StateMachineUpdate
|
||||
import net.corda.core.messaging.*
|
||||
import net.corda.core.node.NodeInfo
|
||||
import net.corda.core.node.services.AttachmentId
|
||||
import net.corda.core.node.services.NetworkMapCache
|
||||
import net.corda.core.node.services.Vault
|
||||
import net.corda.core.node.services.vault.AttachmentQueryCriteria
|
||||
import net.corda.core.node.services.vault.AttachmentSort
|
||||
import net.corda.core.node.services.vault.PageSpecification
|
||||
import net.corda.core.node.services.vault.QueryCriteria
|
||||
import net.corda.core.node.services.vault.Sort
|
||||
import net.corda.core.node.services.vault.*
|
||||
import net.corda.core.serialization.serialize
|
||||
import net.corda.core.transactions.SignedTransaction
|
||||
import net.corda.core.utilities.getOrThrow
|
||||
@ -85,11 +71,10 @@ internal class CordaRPCOpsImpl(
|
||||
}
|
||||
|
||||
override fun acceptNewNetworkParameters(parametersHash: SecureHash) {
|
||||
services.networkMapUpdater.acceptNewNetworkParameters(
|
||||
parametersHash,
|
||||
// TODO When multiple identities design will be better specified this should be signature from node operator.
|
||||
{ hash -> hash.serialize().sign { services.keyManagementService.sign(it.bytes, services.myInfo.legalIdentities[0].owningKey) } }
|
||||
)
|
||||
// TODO When multiple identities design will be better specified this should be signature from node operator.
|
||||
services.networkMapUpdater.acceptNewNetworkParameters(parametersHash) { hash ->
|
||||
hash.serialize().sign { services.keyManagementService.sign(it.bytes, services.myInfo.legalIdentities[0].owningKey) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun networkMapFeed(): DataFeed<List<NodeInfo>, NetworkMapCache.MapChange> {
|
||||
@ -206,7 +191,7 @@ internal class CordaRPCOpsImpl(
|
||||
}
|
||||
|
||||
override fun uploadAttachment(jar: InputStream): SecureHash {
|
||||
return services.attachments.importAttachment(jar, RPC_UPLOADER, null)
|
||||
return services.attachments.privilegedImportAttachment(jar, RPC_UPLOADER, null)
|
||||
}
|
||||
|
||||
override fun uploadAttachmentWithMetadata(jar: InputStream, uploader: String, filename: String): SecureHash {
|
||||
|
@ -64,6 +64,7 @@ import net.corda.nodeapi.internal.bridging.BridgeControlListener
|
||||
import net.corda.nodeapi.internal.config.User
|
||||
import net.corda.nodeapi.internal.crypto.X509Utilities
|
||||
import net.corda.serialization.internal.*
|
||||
import net.corda.nodeapi.internal.persistence.CouldNotCreateDataSourceException
|
||||
import org.h2.jdbc.JdbcSQLException
|
||||
import org.slf4j.Logger
|
||||
import org.slf4j.LoggerFactory
|
||||
@ -71,11 +72,13 @@ import rx.Observable
|
||||
import rx.Scheduler
|
||||
import rx.schedulers.Schedulers
|
||||
import java.net.BindException
|
||||
import java.net.InetAddress
|
||||
import java.nio.file.Path
|
||||
import java.time.Clock
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import javax.management.ObjectName
|
||||
import kotlin.system.exitProcess
|
||||
import java.nio.file.Paths
|
||||
|
||||
class NodeWithInfo(val node: Node, val info: NodeInfo) {
|
||||
val services: StartedNodeServices = object : StartedNodeServices, ServiceHubInternal by node.services, FlowStarter by node.flowStarter {}
|
||||
@ -358,13 +361,20 @@ open class Node(configuration: NodeConfiguration,
|
||||
|
||||
if (databaseUrl != null && databaseUrl.startsWith(h2Prefix)) {
|
||||
val effectiveH2Settings = configuration.effectiveH2Settings
|
||||
|
||||
//forbid execution of arbitrary code via SQL except those classes required by H2 itself
|
||||
System.setProperty("h2.allowedClasses", "org.h2.mvstore.db.MVTableEngine,org.locationtech.jts.geom.Geometry,org.h2.server.TcpServer")
|
||||
if (effectiveH2Settings?.address != null) {
|
||||
if (!InetAddress.getByName(effectiveH2Settings.address.host).isLoopbackAddress
|
||||
&& configuration.dataSourceProperties.getProperty("dataSource.password").isBlank()) {
|
||||
throw CouldNotCreateDataSourceException("Database password is required for H2 server listening on ${InetAddress.getByName(effectiveH2Settings.address.host)}.")
|
||||
}
|
||||
val databaseName = databaseUrl.removePrefix(h2Prefix).substringBefore(';')
|
||||
val baseDir = Paths.get(databaseName).parent.toString()
|
||||
val server = org.h2.tools.Server.createTcpServer(
|
||||
"-tcpPort", effectiveH2Settings.address.port.toString(),
|
||||
"-tcpAllowOthers",
|
||||
"-tcpDaemon",
|
||||
"-baseDir", baseDir,
|
||||
"-key", "node", databaseName)
|
||||
// override interface that createTcpServer listens on (which is always 0.0.0.0)
|
||||
System.setProperty("h2.bindAddress", effectiveH2Settings.address.host)
|
||||
|
@ -39,11 +39,11 @@ import net.corda.node.utilities.registration.UnableToRegisterNodeWithDoormanExce
|
||||
import net.corda.node.utilities.saveToKeyStore
|
||||
import net.corda.node.utilities.saveToTrustStore
|
||||
import net.corda.nodeapi.internal.addShutdownHook
|
||||
import net.corda.nodeapi.internal.persistence.DatabaseIncompatibleException
|
||||
import net.corda.nodeapi.internal.config.UnknownConfigurationKeysException
|
||||
import net.corda.nodeapi.internal.persistence.DatabaseMigrationException
|
||||
import net.corda.nodeapi.internal.persistence.oracleJdbcDriverSerialFilter
|
||||
import net.corda.nodeapi.internal.persistence.CouldNotCreateDataSourceException
|
||||
import net.corda.nodeapi.internal.persistence.DatabaseIncompatibleException
|
||||
import net.corda.tools.shell.InteractiveShell
|
||||
import org.fusesource.jansi.Ansi
|
||||
import org.fusesource.jansi.AnsiConsole
|
||||
@ -337,6 +337,8 @@ open class NodeStartup(val args: Array<String>) {
|
||||
Emoji.renderIfSupported {
|
||||
Node.printWarning("This node is running in developer mode! ${Emoji.developer} This is not safe for production deployment.")
|
||||
}
|
||||
} else {
|
||||
logger.info("The Corda node is running in production mode. If this is a developer environment you can set 'devMode=true' in the node.conf file.")
|
||||
}
|
||||
|
||||
val nodeInfo = node.start()
|
||||
@ -352,7 +354,7 @@ open class NodeStartup(val args: Array<String>) {
|
||||
if (conf.shouldStartLocalShell()) {
|
||||
node.startupComplete.then {
|
||||
try {
|
||||
InteractiveShell.runLocalShell({ node.stop() })
|
||||
InteractiveShell.runLocalShell(node::stop)
|
||||
} catch (e: Throwable) {
|
||||
logger.error("Shell failed to start", e)
|
||||
}
|
||||
|
@ -24,6 +24,7 @@ import net.corda.core.node.services.AttachmentStorage
|
||||
import net.corda.core.serialization.SingletonSerializeAsToken
|
||||
import net.corda.core.utilities.contextLogger
|
||||
import net.corda.node.cordapp.CordappLoader
|
||||
import net.corda.node.services.persistence.AttachmentStorageInternal
|
||||
import java.net.URL
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@ -99,7 +100,13 @@ open class CordappProviderImpl(private val cordappLoader: CordappLoader,
|
||||
cordapps.filter { !it.contractClassNames.isEmpty() }.map {
|
||||
it.jarPath.openStream().use { stream ->
|
||||
try {
|
||||
attachmentStorage.importAttachment(stream, DEPLOYED_CORDAPP_UPLOADER, null)
|
||||
// We can't make attachmentStorage a AttachmentStorageInternal as that ends up requiring
|
||||
// MockAttachmentStorage to implement it.
|
||||
if (attachmentStorage is AttachmentStorageInternal) {
|
||||
attachmentStorage.privilegedImportAttachment(stream, DEPLOYED_CORDAPP_UPLOADER, null)
|
||||
} else {
|
||||
attachmentStorage.importAttachment(stream, DEPLOYED_CORDAPP_UPLOADER, null)
|
||||
}
|
||||
} catch (faee: java.nio.file.FileAlreadyExistsException) {
|
||||
AttachmentId.parse(faee.message!!)
|
||||
}
|
||||
|
@ -31,6 +31,7 @@ import net.corda.node.internal.cordapp.CordappProviderInternal
|
||||
import net.corda.node.services.config.NodeConfiguration
|
||||
import net.corda.node.services.messaging.MessagingService
|
||||
import net.corda.node.services.network.NetworkMapUpdater
|
||||
import net.corda.node.services.persistence.AttachmentStorageInternal
|
||||
import net.corda.node.services.statemachine.ExternalEvent
|
||||
import net.corda.node.services.statemachine.FlowStateMachineImpl
|
||||
import net.corda.nodeapi.internal.persistence.CordaPersistence
|
||||
@ -115,6 +116,7 @@ interface ServiceHubInternal : ServiceHub {
|
||||
}
|
||||
}
|
||||
|
||||
override val attachments: AttachmentStorageInternal
|
||||
override val vaultService: VaultServiceInternal
|
||||
/**
|
||||
* A map of hash->tx where tx has been signature/contract validated and the states are known to be correct.
|
||||
|
@ -80,9 +80,7 @@ class NetworkMapCacheImpl(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extremely simple in-memory cache of the network map.
|
||||
*/
|
||||
/** Database-based network map cache. */
|
||||
@ThreadSafe
|
||||
open class PersistentNetworkMapCache(private val database: CordaPersistence) : SingletonSerializeAsToken(), NetworkMapCacheBaseInternal {
|
||||
companion object {
|
||||
|
@ -0,0 +1,13 @@
|
||||
package net.corda.node.services.persistence
|
||||
|
||||
import net.corda.core.node.services.AttachmentId
|
||||
import net.corda.core.node.services.AttachmentStorage
|
||||
import java.io.InputStream
|
||||
|
||||
interface AttachmentStorageInternal : AttachmentStorage {
|
||||
/**
|
||||
* This is the same as [importAttachment] expect there are no checks done on the uploader field. This API is internal
|
||||
* and is only for the node.
|
||||
*/
|
||||
fun privilegedImportAttachment(jar: InputStream, uploader: String, filename: String?): AttachmentId
|
||||
}
|
@ -22,12 +22,8 @@ import net.corda.core.contracts.ContractAttachment
|
||||
import net.corda.core.contracts.ContractClassName
|
||||
import net.corda.core.crypto.SecureHash
|
||||
import net.corda.core.crypto.sha256
|
||||
import net.corda.core.internal.AbstractAttachment
|
||||
import net.corda.core.internal.UNKNOWN_UPLOADER
|
||||
import net.corda.core.internal.VisibleForTesting
|
||||
import net.corda.core.internal.readFully
|
||||
import net.corda.core.internal.*
|
||||
import net.corda.core.node.services.AttachmentId
|
||||
import net.corda.core.node.services.AttachmentStorage
|
||||
import net.corda.core.node.services.vault.AttachmentQueryCriteria
|
||||
import net.corda.core.node.services.vault.AttachmentSort
|
||||
import net.corda.core.serialization.*
|
||||
@ -60,10 +56,12 @@ class NodeAttachmentService(
|
||||
private val database: CordaPersistence,
|
||||
attachmentContentCacheSize: Long = NodeConfiguration.defaultAttachmentContentCacheSize,
|
||||
attachmentCacheBound: Long = NodeConfiguration.defaultAttachmentCacheBound
|
||||
) : AttachmentStorage, SingletonSerializeAsToken() {
|
||||
) : AttachmentStorageInternal, SingletonSerializeAsToken() {
|
||||
companion object {
|
||||
private val log = contextLogger()
|
||||
|
||||
private val PRIVILEGED_UPLOADERS = listOf(DEPLOYED_CORDAPP_UPLOADER, RPC_UPLOADER, P2P_UPLOADER, UNKNOWN_UPLOADER)
|
||||
|
||||
// Just iterate over the entries with verification enabled: should be good enough to catch mistakes.
|
||||
// Note that JarInputStream won't throw any kind of error at all if the file stream is in fact not
|
||||
// a ZIP! It'll just pretend it's an empty archive, which is kind of stupid but that's how it works.
|
||||
@ -238,10 +236,9 @@ class NodeAttachmentService(
|
||||
}
|
||||
}
|
||||
|
||||
private val attachmentCache = NonInvalidatingCache<SecureHash, Optional<Attachment>>(
|
||||
attachmentCacheBound,
|
||||
{ key -> Optional.ofNullable(createAttachment(key)) }
|
||||
)
|
||||
private val attachmentCache = NonInvalidatingCache<SecureHash, Optional<Attachment>>(attachmentCacheBound) { key ->
|
||||
Optional.ofNullable(createAttachment(key))
|
||||
}
|
||||
|
||||
private fun createAttachment(key: SecureHash): Attachment? {
|
||||
val content = attachmentContentCache.get(key)!!
|
||||
@ -268,6 +265,18 @@ class NodeAttachmentService(
|
||||
}
|
||||
|
||||
override fun importAttachment(jar: InputStream, uploader: String, filename: String?): AttachmentId {
|
||||
require(uploader !in PRIVILEGED_UPLOADERS) { "$uploader is a reserved uploader token" }
|
||||
if (uploader.startsWith("$P2P_UPLOADER:")) {
|
||||
// FetchAttachmentsFlow is in core and thus doesn't have access to AttachmentStorageInternal to call
|
||||
// privilegedImportAttachment
|
||||
require(Thread.currentThread().stackTrace.any { it.className == FetchAttachmentsFlow::class.java.name }) {
|
||||
"$P2P_UPLOADER is a reserved uploader token prefix"
|
||||
}
|
||||
}
|
||||
return import(jar, uploader, filename)
|
||||
}
|
||||
|
||||
override fun privilegedImportAttachment(jar: InputStream, uploader: String, filename: String?): AttachmentId {
|
||||
return import(jar, uploader, filename)
|
||||
}
|
||||
|
||||
@ -292,7 +301,13 @@ class NodeAttachmentService(
|
||||
if (!hasAttachment(id)) {
|
||||
checkIsAValidJAR(bytes.inputStream())
|
||||
val session = currentDBSession()
|
||||
val attachment = NodeAttachmentService.DBAttachment(attId = id.toString(), content = bytes, uploader = uploader, filename = filename, contractClassNames = contractClassNames)
|
||||
val attachment = NodeAttachmentService.DBAttachment(
|
||||
attId = id.toString(),
|
||||
content = bytes,
|
||||
uploader = uploader,
|
||||
filename = filename,
|
||||
contractClassNames = contractClassNames
|
||||
)
|
||||
session.save(attachment)
|
||||
attachmentCount.inc()
|
||||
log.info("Stored new attachment $id")
|
||||
@ -305,10 +320,12 @@ class NodeAttachmentService(
|
||||
}
|
||||
|
||||
@Suppress("OverridingDeprecatedMember")
|
||||
override fun importOrGetAttachment(jar: InputStream): AttachmentId = try {
|
||||
import(jar, UNKNOWN_UPLOADER, null)
|
||||
} catch (faee: java.nio.file.FileAlreadyExistsException) {
|
||||
AttachmentId.parse(faee.message!!)
|
||||
override fun importOrGetAttachment(jar: InputStream): AttachmentId {
|
||||
return try {
|
||||
import(jar, UNKNOWN_UPLOADER, null)
|
||||
} catch (faee: java.nio.file.FileAlreadyExistsException) {
|
||||
AttachmentId.parse(faee.message!!)
|
||||
}
|
||||
}
|
||||
|
||||
override fun queryAttachments(criteria: AttachmentQueryCriteria, sorting: AttachmentSort?): List<AttachmentId> {
|
||||
|
@ -95,10 +95,10 @@ class NodeVaultService(
|
||||
log.trace { "State update of type: $concreteType" }
|
||||
val seen = contractStateTypeMappings.any { it.value.contains(concreteType.name) }
|
||||
if (!seen) {
|
||||
val contractInterfaces = deriveContractInterfaces(concreteType)
|
||||
contractInterfaces.map {
|
||||
val contractInterface = contractStateTypeMappings.getOrPut(it.name) { mutableSetOf() }
|
||||
contractInterface.add(concreteType.name)
|
||||
val contractTypes = deriveContractTypes(concreteType)
|
||||
contractTypes.map {
|
||||
val contractStateType = contractStateTypeMappings.getOrPut(it.name) { mutableSetOf() }
|
||||
contractStateType.add(concreteType.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -541,10 +541,10 @@ class NodeVaultService(
|
||||
null
|
||||
}
|
||||
concreteType?.let {
|
||||
val contractInterfaces = deriveContractInterfaces(it)
|
||||
contractInterfaces.map {
|
||||
val contractInterface = contractStateTypeMappings.getOrPut(it.name) { mutableSetOf() }
|
||||
contractInterface.add(it.name)
|
||||
val contractTypes = deriveContractTypes(it)
|
||||
contractTypes.map {
|
||||
val contractStateType = contractStateTypeMappings.getOrPut(it.name) { mutableSetOf() }
|
||||
contractStateType.add(it.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -553,14 +553,20 @@ class NodeVaultService(
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T : ContractState> deriveContractInterfaces(clazz: Class<T>): Set<Class<T>> {
|
||||
val myInterfaces: MutableSet<Class<T>> = mutableSetOf()
|
||||
clazz.interfaces.forEach {
|
||||
if (it != ContractState::class.java) {
|
||||
myInterfaces.add(uncheckedCast(it))
|
||||
myInterfaces.addAll(deriveContractInterfaces(uncheckedCast(it)))
|
||||
private fun <T : ContractState> deriveContractTypes(clazz: Class<T>): Set<Class<T>> {
|
||||
val myTypes : MutableSet<Class<T>> = mutableSetOf()
|
||||
clazz.superclass?.let {
|
||||
if (!it.isInstance(Any::class)) {
|
||||
myTypes.add(uncheckedCast(it))
|
||||
myTypes.addAll(deriveContractTypes(uncheckedCast(it)))
|
||||
}
|
||||
}
|
||||
return myInterfaces
|
||||
clazz.interfaces.forEach {
|
||||
if (it != ContractState::class.java) {
|
||||
myTypes.add(uncheckedCast(it))
|
||||
myTypes.addAll(deriveContractTypes(uncheckedCast(it)))
|
||||
}
|
||||
}
|
||||
return myTypes
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,72 @@
|
||||
package net.corda.node.internal
|
||||
|
||||
import co.paralleluniverse.fibers.Suspendable
|
||||
import net.corda.core.flows.FlowLogic
|
||||
import net.corda.core.flows.FlowSession
|
||||
import net.corda.core.flows.InitiatedBy
|
||||
import net.corda.core.flows.InitiatingFlow
|
||||
import net.corda.core.identity.CordaX500Name
|
||||
import net.corda.core.identity.Party
|
||||
import net.corda.core.utilities.unwrap
|
||||
import net.corda.testing.core.singleIdentity
|
||||
import net.corda.testing.node.MockNetwork
|
||||
import net.corda.testing.node.MockNodeParameters
|
||||
import net.corda.testing.node.StartedMockNode
|
||||
import org.assertj.core.api.Assertions.assertThatIllegalStateException
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
class FlowRegistrationTest {
|
||||
|
||||
lateinit var mockNetwork: MockNetwork
|
||||
lateinit var initiator: StartedMockNode
|
||||
lateinit var responder: StartedMockNode
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
// no cordapps scanned so it can be tested in isolation
|
||||
mockNetwork = MockNetwork(emptyList())
|
||||
initiator = mockNetwork.createNode(MockNodeParameters(legalName = CordaX500Name("initiator", "Reading", "GB")))
|
||||
responder = mockNetwork.createNode(MockNodeParameters(legalName = CordaX500Name("responder", "Reading", "GB")))
|
||||
mockNetwork.runNetwork()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
mockNetwork.stopNodes()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `startup fails when two flows initiated by the same flow are registered`() {
|
||||
// register the same flow twice to invoke the error without causing errors in other tests
|
||||
responder.registerInitiatedFlow(Responder::class.java)
|
||||
assertThatIllegalStateException().isThrownBy { responder.registerInitiatedFlow(Responder::class.java) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single initiated flow can be registered without error`() {
|
||||
responder.registerInitiatedFlow(Responder::class.java)
|
||||
val result = initiator.startFlow(Initiator(responder.info.singleIdentity()))
|
||||
mockNetwork.runNetwork()
|
||||
assertNotNull(result.get())
|
||||
}
|
||||
}
|
||||
|
||||
@InitiatingFlow
|
||||
class Initiator(val party: Party) : FlowLogic<String>() {
|
||||
@Suspendable
|
||||
override fun call(): String {
|
||||
return initiateFlow(party).sendAndReceive<String>("Hello there").unwrap { it }
|
||||
}
|
||||
}
|
||||
|
||||
@InitiatedBy(Initiator::class)
|
||||
private class Responder(val session: FlowSession) : FlowLogic<Unit>() {
|
||||
@Suspendable
|
||||
override fun call() {
|
||||
session.receive<String>().unwrap { it }
|
||||
session.send("What's up")
|
||||
}
|
||||
}
|
@ -10,26 +10,34 @@
|
||||
|
||||
package net.corda.node.services.persistence
|
||||
|
||||
import co.paralleluniverse.fibers.Suspendable
|
||||
import com.codahale.metrics.MetricRegistry
|
||||
import com.google.common.jimfs.Configuration
|
||||
import com.google.common.jimfs.Jimfs
|
||||
import net.corda.core.crypto.SecureHash
|
||||
import net.corda.core.crypto.sha256
|
||||
import net.corda.core.flows.FlowLogic
|
||||
import net.corda.core.internal.*
|
||||
import net.corda.core.node.services.vault.AttachmentQueryCriteria
|
||||
import net.corda.core.node.services.vault.AttachmentSort
|
||||
import net.corda.core.node.services.vault.Builder
|
||||
import net.corda.core.node.services.vault.Sort
|
||||
import net.corda.core.utilities.getOrThrow
|
||||
import net.corda.node.internal.configureDatabase
|
||||
import net.corda.node.services.transactions.PersistentUniquenessProvider
|
||||
import net.corda.nodeapi.internal.persistence.CordaPersistence
|
||||
import net.corda.nodeapi.internal.persistence.DatabaseConfig
|
||||
import net.corda.testing.internal.LogHelper
|
||||
import net.corda.testing.node.MockServices.Companion.makeTestDataSourceProperties
|
||||
import net.corda.testing.node.internal.InternalMockNetwork
|
||||
import net.corda.testing.node.internal.startFlow
|
||||
import org.assertj.core.api.Assertions.assertThatIllegalArgumentException
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.OutputStream
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.nio.file.FileAlreadyExistsException
|
||||
import java.nio.file.FileSystem
|
||||
@ -40,7 +48,7 @@ import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class NodeAttachmentStorageTest {
|
||||
class NodeAttachmentServiceTest {
|
||||
// Use an in memory file system for testing attachment storage.
|
||||
private lateinit var fs: FileSystem
|
||||
private lateinit var database: CordaPersistence
|
||||
@ -195,7 +203,7 @@ class NodeAttachmentStorageTest {
|
||||
@Ignore("We need to be able to restart nodes - make importing attachments idempotent?")
|
||||
@Test
|
||||
fun `duplicates not allowed`() {
|
||||
val (testJar, _) = makeTestJar()
|
||||
val (testJar) = makeTestJar()
|
||||
testJar.read {
|
||||
storage.importAttachment(it, "test", null)
|
||||
}
|
||||
@ -208,7 +216,7 @@ class NodeAttachmentStorageTest {
|
||||
|
||||
@Test
|
||||
fun `corrupt entry throws exception`() {
|
||||
val (testJar, _) = makeTestJar()
|
||||
val (testJar) = makeTestJar()
|
||||
val id = database.transaction {
|
||||
val id = testJar.read { storage.importAttachment(it, "test", null) }
|
||||
|
||||
@ -243,23 +251,68 @@ class NodeAttachmentStorageTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `using reserved uploader tokens`() {
|
||||
val (testJar) = makeTestJar()
|
||||
|
||||
fun assertImportFails(uploader: String) {
|
||||
testJar.read {
|
||||
assertThatIllegalArgumentException().isThrownBy {
|
||||
storage.importAttachment(it, uploader, null)
|
||||
}.withMessageContaining(uploader)
|
||||
}
|
||||
}
|
||||
|
||||
database.transaction {
|
||||
assertImportFails(DEPLOYED_CORDAPP_UPLOADER)
|
||||
assertImportFails(P2P_UPLOADER)
|
||||
assertImportFails(RPC_UPLOADER)
|
||||
assertImportFails(UNKNOWN_UPLOADER)
|
||||
}
|
||||
|
||||
// Import an attachment similar to how net.corda.core.internal.FetchAttachmentsFlow does it.
|
||||
InternalMockNetwork(threadPerNode = true).use { mockNet ->
|
||||
val node = mockNet.createNode()
|
||||
val result = node.services.startFlow(FetchAttachmentsFlow()).resultFuture
|
||||
assertThatIllegalArgumentException().isThrownBy {
|
||||
result.getOrThrow()
|
||||
}.withMessageContaining(P2P_UPLOADER)
|
||||
}
|
||||
}
|
||||
|
||||
// Not the real FetchAttachmentsFlow!
|
||||
private class FetchAttachmentsFlow : FlowLogic<Unit>() {
|
||||
@Suspendable
|
||||
override fun call() {
|
||||
val baos = ByteArrayOutputStream()
|
||||
makeTestJar(baos)
|
||||
serviceHub.attachments.importAttachment(baos.toByteArray().inputStream(), "$P2P_UPLOADER:${ourIdentity.name}", null)
|
||||
}
|
||||
}
|
||||
|
||||
private var counter = 0
|
||||
private fun makeTestJar(extraEntries: List<Pair<String, String>> = emptyList()): Pair<Path, SecureHash> {
|
||||
counter++
|
||||
val file = fs.getPath("$counter.jar")
|
||||
file.write {
|
||||
val jar = JarOutputStream(it)
|
||||
jar.putNextEntry(JarEntry("test1.txt"))
|
||||
jar.write("This is some useful content".toByteArray())
|
||||
jar.closeEntry()
|
||||
jar.putNextEntry(JarEntry("test2.txt"))
|
||||
jar.write("Some more useful content".toByteArray())
|
||||
extraEntries.forEach {
|
||||
jar.putNextEntry(JarEntry(it.first))
|
||||
jar.write(it.second.toByteArray())
|
||||
}
|
||||
jar.closeEntry()
|
||||
}
|
||||
makeTestJar(file.outputStream(), extraEntries)
|
||||
return Pair(file, file.readAll().sha256())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private fun makeTestJar(output: OutputStream, extraEntries: List<Pair<String, String>> = emptyList()) {
|
||||
output.use {
|
||||
val jar = JarOutputStream(it)
|
||||
jar.putNextEntry(JarEntry("test1.txt"))
|
||||
jar.write("This is some useful content".toByteArray())
|
||||
jar.closeEntry()
|
||||
jar.putNextEntry(JarEntry("test2.txt"))
|
||||
jar.write("Some more useful content".toByteArray())
|
||||
extraEntries.forEach {
|
||||
jar.putNextEntry(JarEntry(it.first))
|
||||
jar.write(it.second.toByteArray())
|
||||
}
|
||||
jar.closeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -63,47 +63,41 @@ class FlowFrameworkTests {
|
||||
init {
|
||||
LogHelper.setLevel("+net.corda.flow")
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var mockNet: InternalMockNetwork
|
||||
private lateinit var aliceNode: TestStartedNode
|
||||
private lateinit var bobNode: TestStartedNode
|
||||
private lateinit var alice: Party
|
||||
private lateinit var bob: Party
|
||||
private lateinit var notaryIdentity: Party
|
||||
private val receivedSessionMessages = ArrayList<SessionTransfer>()
|
||||
private lateinit var mockNet: InternalMockNetwork
|
||||
private lateinit var aliceNode: TestStartedNode
|
||||
private lateinit var bobNode: TestStartedNode
|
||||
private lateinit var alice: Party
|
||||
private lateinit var bob: Party
|
||||
private lateinit var notaryIdentity: Party
|
||||
private val receivedSessionMessages = ArrayList<SessionTransfer>()
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun beforeClass() {
|
||||
mockNet = InternalMockNetwork(
|
||||
cordappsForAllNodes = cordappsForPackages("net.corda.finance.contracts", "net.corda.testing.contracts"),
|
||||
servicePeerAllocationStrategy = RoundRobin()
|
||||
)
|
||||
@Before
|
||||
fun setUpMockNet() {
|
||||
mockNet = InternalMockNetwork(
|
||||
cordappsForAllNodes = cordappsForPackages("net.corda.finance.contracts", "net.corda.testing.contracts"),
|
||||
servicePeerAllocationStrategy = RoundRobin()
|
||||
)
|
||||
|
||||
aliceNode = mockNet.createNode(InternalMockNodeParameters(legalName = ALICE_NAME))
|
||||
bobNode = mockNet.createNode(InternalMockNodeParameters(legalName = BOB_NAME))
|
||||
aliceNode = mockNet.createNode(InternalMockNodeParameters(legalName = ALICE_NAME))
|
||||
bobNode = mockNet.createNode(InternalMockNodeParameters(legalName = BOB_NAME))
|
||||
|
||||
// Extract identities
|
||||
alice = aliceNode.info.singleIdentity()
|
||||
bob = bobNode.info.singleIdentity()
|
||||
notaryIdentity = mockNet.defaultNotaryIdentity
|
||||
// Extract identities
|
||||
alice = aliceNode.info.singleIdentity()
|
||||
bob = bobNode.info.singleIdentity()
|
||||
notaryIdentity = mockNet.defaultNotaryIdentity
|
||||
|
||||
receivedSessionMessagesObservable().forEach { receivedSessionMessages += it }
|
||||
}
|
||||
|
||||
private fun receivedSessionMessagesObservable(): Observable<SessionTransfer> {
|
||||
return mockNet.messagingNetwork.receivedMessages.toSessionTransfers()
|
||||
}
|
||||
|
||||
@AfterClass @JvmStatic
|
||||
fun afterClass() {
|
||||
mockNet.stopNodes()
|
||||
}
|
||||
receivedSessionMessagesObservable().forEach { receivedSessionMessages += it }
|
||||
}
|
||||
|
||||
private fun receivedSessionMessagesObservable(): Observable<SessionTransfer> {
|
||||
return mockNet.messagingNetwork.receivedMessages.toSessionTransfers()
|
||||
}
|
||||
|
||||
@After
|
||||
fun cleanUp() {
|
||||
mockNet.stopNodes()
|
||||
receivedSessionMessages.clear()
|
||||
}
|
||||
|
||||
@ -484,45 +478,38 @@ class FlowFrameworkTripartyTests {
|
||||
private lateinit var charlie: Party
|
||||
private lateinit var notaryIdentity: Party
|
||||
private val receivedSessionMessages = ArrayList<SessionTransfer>()
|
||||
}
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun beforeClass() {
|
||||
mockNet = InternalMockNetwork(
|
||||
cordappsForAllNodes = cordappsForPackages("net.corda.finance.contracts", "net.corda.testing.contracts"),
|
||||
servicePeerAllocationStrategy = RoundRobin()
|
||||
)
|
||||
@Before
|
||||
fun setUpGlobalMockNet() {
|
||||
mockNet = InternalMockNetwork(
|
||||
cordappsForAllNodes = cordappsForPackages("net.corda.finance.contracts", "net.corda.testing.contracts"),
|
||||
servicePeerAllocationStrategy = RoundRobin()
|
||||
)
|
||||
|
||||
aliceNode = mockNet.createNode(InternalMockNodeParameters(legalName = ALICE_NAME))
|
||||
bobNode = mockNet.createNode(InternalMockNodeParameters(legalName = BOB_NAME))
|
||||
charlieNode = mockNet.createNode(InternalMockNodeParameters(legalName = CHARLIE_NAME))
|
||||
aliceNode = mockNet.createNode(InternalMockNodeParameters(legalName = ALICE_NAME))
|
||||
bobNode = mockNet.createNode(InternalMockNodeParameters(legalName = BOB_NAME))
|
||||
charlieNode = mockNet.createNode(InternalMockNodeParameters(legalName = CHARLIE_NAME))
|
||||
|
||||
|
||||
// Extract identities
|
||||
alice = aliceNode.info.singleIdentity()
|
||||
bob = bobNode.info.singleIdentity()
|
||||
charlie = charlieNode.info.singleIdentity()
|
||||
notaryIdentity = mockNet.defaultNotaryIdentity
|
||||
|
||||
receivedSessionMessagesObservable().forEach { receivedSessionMessages += it }
|
||||
}
|
||||
|
||||
@AfterClass @JvmStatic
|
||||
fun afterClass() {
|
||||
mockNet.stopNodes()
|
||||
}
|
||||
|
||||
private fun receivedSessionMessagesObservable(): Observable<SessionTransfer> {
|
||||
return mockNet.messagingNetwork.receivedMessages.toSessionTransfers()
|
||||
}
|
||||
// Extract identities
|
||||
alice = aliceNode.info.singleIdentity()
|
||||
bob = bobNode.info.singleIdentity()
|
||||
charlie = charlieNode.info.singleIdentity()
|
||||
notaryIdentity = mockNet.defaultNotaryIdentity
|
||||
|
||||
receivedSessionMessagesObservable().forEach { receivedSessionMessages += it }
|
||||
}
|
||||
|
||||
@After
|
||||
fun cleanUp() {
|
||||
mockNet.stopNodes()
|
||||
receivedSessionMessages.clear()
|
||||
}
|
||||
|
||||
private fun receivedSessionMessagesObservable(): Observable<SessionTransfer> {
|
||||
return mockNet.messagingNetwork.receivedMessages.toSessionTransfers()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sending to multiple parties`() {
|
||||
|
@ -12,12 +12,14 @@ package net.corda.node.services.vault
|
||||
|
||||
import net.corda.core.contracts.*
|
||||
import net.corda.core.crypto.*
|
||||
import net.corda.core.identity.AbstractParty
|
||||
import net.corda.core.identity.CordaX500Name
|
||||
import net.corda.core.identity.Party
|
||||
import net.corda.core.internal.packageName
|
||||
import net.corda.core.node.services.*
|
||||
import net.corda.core.node.services.vault.*
|
||||
import net.corda.core.node.services.vault.QueryCriteria.*
|
||||
import net.corda.core.transactions.LedgerTransaction
|
||||
import net.corda.core.transactions.TransactionBuilder
|
||||
import net.corda.core.utilities.*
|
||||
import net.corda.finance.*
|
||||
@ -37,6 +39,7 @@ import net.corda.nodeapi.internal.persistence.DatabaseConfig
|
||||
import net.corda.nodeapi.internal.persistence.DatabaseTransaction
|
||||
import net.corda.testing.core.*
|
||||
import net.corda.testing.internal.TEST_TX_TIME
|
||||
import net.corda.testing.internal.chooseIdentity
|
||||
import net.corda.testing.internal.rigorousMock
|
||||
import net.corda.testing.internal.vault.*
|
||||
import net.corda.testing.node.MockServices
|
||||
@ -125,7 +128,8 @@ open class VaultQueryTestRule : ExternalResource(), VaultQueryParties {
|
||||
"net.corda.finance.contracts",
|
||||
CashSchemaV1::class.packageName,
|
||||
DummyLinearStateSchemaV1::class.packageName,
|
||||
SampleCashSchemaV3::class.packageName)
|
||||
SampleCashSchemaV3::class.packageName,
|
||||
VaultQueryTestsBase.MyContractClass::class.packageName)
|
||||
|
||||
override lateinit var services: MockServices
|
||||
override lateinit var vaultFiller: VaultFiller
|
||||
@ -263,6 +267,43 @@ abstract class VaultQueryTestsBase : VaultQueryParties {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query by interface for a contract class extending a parent contract class`() {
|
||||
database.transaction {
|
||||
|
||||
// build custom contract and store in vault
|
||||
val me = services.myInfo.chooseIdentity()
|
||||
val state = MyState("myState", listOf(me))
|
||||
val stateAndContract = StateAndContract(state, MYCONTRACT_ID)
|
||||
val utx = TransactionBuilder(notary = notaryServices.myInfo.singleIdentity()).withItems(stateAndContract).withItems(dummyCommand())
|
||||
services.recordTransactions(services.signInitialTransaction(utx))
|
||||
|
||||
// query vault by Child class
|
||||
val criteria = VaultQueryCriteria() // default is UNCONSUMED
|
||||
val queryByMyState = vaultService.queryBy<MyState>(criteria)
|
||||
assertThat(queryByMyState.states).hasSize(1)
|
||||
|
||||
// query vault by Parent class
|
||||
val queryByBaseState = vaultService.queryBy<BaseState>(criteria)
|
||||
assertThat(queryByBaseState.states).hasSize(1)
|
||||
|
||||
// query vault by extended Contract Interface
|
||||
val queryByContract = vaultService.queryBy<MyContractInterface>(criteria)
|
||||
assertThat(queryByContract.states).hasSize(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Beware: do not use `MyContractClass::class.qualifiedName` as this returns a fully qualified name using "dot" notation for enclosed class
|
||||
val MYCONTRACT_ID = "net.corda.node.services.vault.VaultQueryTestsBase\$MyContractClass"
|
||||
|
||||
open class MyContractClass : Contract {
|
||||
override fun verify(tx: LedgerTransaction) {}
|
||||
}
|
||||
|
||||
interface MyContractInterface : ContractState
|
||||
open class BaseState(override val participants: List<AbstractParty> = emptyList()) : MyContractInterface
|
||||
data class MyState(val name: String, override val participants: List<AbstractParty> = emptyList()) : BaseState(participants)
|
||||
|
||||
@Test
|
||||
fun `unconsumed states simple`() {
|
||||
database.transaction {
|
||||
|
@ -63,6 +63,7 @@ task deployNodes(type: net.corda.plugins.Cordform, dependsOn: ['jar']) {
|
||||
address "localhost:10003"
|
||||
adminAddress "localhost:10004"
|
||||
}
|
||||
extraConfig = ['h2Settings.address' : 'localhost:10012']
|
||||
}
|
||||
node {
|
||||
name "O=Bank A,L=London,C=GB"
|
||||
@ -73,6 +74,7 @@ task deployNodes(type: net.corda.plugins.Cordform, dependsOn: ['jar']) {
|
||||
address "localhost:10006"
|
||||
adminAddress "localhost:10007"
|
||||
}
|
||||
extraConfig = ['h2Settings.address' : 'localhost:10013']
|
||||
}
|
||||
node {
|
||||
name "O=Bank B,L=New York,C=US"
|
||||
@ -84,6 +86,7 @@ task deployNodes(type: net.corda.plugins.Cordform, dependsOn: ['jar']) {
|
||||
webPort 10010
|
||||
cordapps = []
|
||||
rpcUsers = ext.rpcUsers
|
||||
extraConfig = ['h2Settings.address' : 'localhost:10014']
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -54,10 +54,12 @@ class BankOfCordaCordform : CordformDefinition() {
|
||||
adminAddress("localhost:10004")
|
||||
}
|
||||
devMode(true)
|
||||
extraConfig = mapOf("h2Settings" to mapOf("address" to "localhost:10016"))
|
||||
}
|
||||
node {
|
||||
name(BOC_NAME)
|
||||
extraConfig = mapOf("custom" to mapOf("issuableCurrencies" to listOf("USD")))
|
||||
extraConfig = mapOf("custom" to mapOf("issuableCurrencies" to listOf("USD")),
|
||||
"h2Settings" to mapOf("address" to "localhost:10017"))
|
||||
p2pPort(10005)
|
||||
rpcSettings {
|
||||
address("localhost:$BOC_RPC_PORT")
|
||||
@ -77,6 +79,7 @@ class BankOfCordaCordform : CordformDefinition() {
|
||||
webPort(10010)
|
||||
rpcUsers(User(BIGCORP_RPC_USER, BIGCORP_RPC_PWD, setOf(all())))
|
||||
devMode(true)
|
||||
extraConfig = mapOf("h2Settings" to mapOf("address" to "localhost:10018"))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -32,10 +32,11 @@ task deployNodes(type: net.corda.plugins.Cordform, dependsOn: ['jar']) {
|
||||
port 10003
|
||||
adminPort 10004
|
||||
}
|
||||
extraConfig = ['h2Settings.address' : 'localhost:10005']
|
||||
}
|
||||
node {
|
||||
name "O=Bank A,L=London,C=GB"
|
||||
p2pPort 10005
|
||||
p2pPort 10006
|
||||
cordapps = []
|
||||
rpcUsers = ext.rpcUsers
|
||||
// This configures the default cordapp for this node
|
||||
@ -46,10 +47,11 @@ task deployNodes(type: net.corda.plugins.Cordform, dependsOn: ['jar']) {
|
||||
port 10007
|
||||
adminPort 10008
|
||||
}
|
||||
extraConfig = ['h2Settings.address' : 'localhost:10009']
|
||||
}
|
||||
node {
|
||||
name "O=Bank B,L=New York,C=US"
|
||||
p2pPort 10009
|
||||
p2pPort 10010
|
||||
cordapps = []
|
||||
rpcUsers = ext.rpcUsers
|
||||
// This configures the default cordapp for this node
|
||||
@ -60,5 +62,6 @@ task deployNodes(type: net.corda.plugins.Cordform, dependsOn: ['jar']) {
|
||||
port 10011
|
||||
adminPort 10012
|
||||
}
|
||||
extraConfig = ['h2Settings.address' : 'localhost:10013']
|
||||
}
|
||||
}
|
@ -80,6 +80,7 @@ task deployNodes(type: net.corda.plugins.Cordform, dependsOn: ['jar']) {
|
||||
cordapps = ["${project(":finance").group}:finance:$corda_release_version"]
|
||||
rpcUsers = rpcUsersList
|
||||
useTestClock true
|
||||
extraConfig = ['h2Settings.address' : 'localhost:10024']
|
||||
}
|
||||
node {
|
||||
name "O=Bank A,L=London,C=GB"
|
||||
@ -91,6 +92,7 @@ task deployNodes(type: net.corda.plugins.Cordform, dependsOn: ['jar']) {
|
||||
cordapps = ["${project(":finance").group}:finance:$corda_release_version"]
|
||||
rpcUsers = rpcUsersList
|
||||
useTestClock true
|
||||
extraConfig = ['h2Settings.address' : 'localhost:10027']
|
||||
}
|
||||
node {
|
||||
name "O=Bank B,L=New York,C=US"
|
||||
@ -102,6 +104,7 @@ task deployNodes(type: net.corda.plugins.Cordform, dependsOn: ['jar']) {
|
||||
cordapps = ["${project.group}:finance:$corda_release_version"]
|
||||
rpcUsers = rpcUsersList
|
||||
useTestClock true
|
||||
extraConfig = ['h2Settings.address' : 'localhost:10030']
|
||||
}
|
||||
node {
|
||||
name "O=Regulator,L=Moscow,C=RU"
|
||||
@ -114,6 +117,7 @@ task deployNodes(type: net.corda.plugins.Cordform, dependsOn: ['jar']) {
|
||||
cordapps = ["${project(":finance").group}:finance:$corda_release_version"]
|
||||
rpcUsers = rpcUsersList
|
||||
useTestClock true
|
||||
extraConfig = ['h2Settings.address' : 'localhost:10033']
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -24,7 +24,7 @@ task deployNodes(type: net.corda.plugins.Cordform, dependsOn: ['jar']) {
|
||||
port 10003
|
||||
adminPort 10004
|
||||
}
|
||||
h2Port 20004
|
||||
extraConfig = ['h2Settings.address' : 'localhost:20004']
|
||||
}
|
||||
node {
|
||||
name "O=Bank A,L=London,C=GB"
|
||||
@ -35,6 +35,7 @@ task deployNodes(type: net.corda.plugins.Cordform, dependsOn: ['jar']) {
|
||||
port 10007
|
||||
adminPort 10008
|
||||
}
|
||||
extraConfig = ['h2Settings.address' : 'localhost:0']
|
||||
}
|
||||
node {
|
||||
name "O=Bank B,L=New York,C=US"
|
||||
@ -45,5 +46,6 @@ task deployNodes(type: net.corda.plugins.Cordform, dependsOn: ['jar']) {
|
||||
port 10011
|
||||
adminPort 10012
|
||||
}
|
||||
extraConfig = ['h2Settings.address' : 'localhost:0']
|
||||
}
|
||||
}
|
@ -52,7 +52,7 @@ by using the H2 web console:
|
||||
Each node outputs its connection string in the terminal window as it starts up. In a terminal window where a **notary** node is running,
|
||||
look for the following string:
|
||||
|
||||
``Database connection url is : jdbc:h2:tcp://10.18.0.150:56736/node``
|
||||
``Database connection url is : jdbc:h2:tcp://localhost:56736/node``
|
||||
|
||||
You can use the string on the right to connect to the h2 database: just paste it into the `JDBC URL` field and click *Connect*.
|
||||
You will be presented with a web application that enumerates all the available tables and provides an interface for you to query them using SQL
|
||||
|
@ -45,6 +45,7 @@ class BFTNotaryCordform : CordformDefinition() {
|
||||
}
|
||||
rpcUsers(notaryDemoUser)
|
||||
devMode(true)
|
||||
extraConfig = mapOf("h2Settings" to mapOf("address" to "localhost:0"))
|
||||
}
|
||||
node {
|
||||
name(BOB_NAME)
|
||||
@ -54,11 +55,13 @@ class BFTNotaryCordform : CordformDefinition() {
|
||||
adminAddress("localhost:10106")
|
||||
}
|
||||
devMode(true)
|
||||
extraConfig = mapOf("h2Settings" to mapOf("address" to "localhost:0"))
|
||||
}
|
||||
val clusterAddresses = (0 until clusterSize).map { NetworkHostAndPort("localhost", 11000 + it * 10) }
|
||||
fun notaryNode(replicaId: Int, configure: CordformNode.() -> Unit) = node {
|
||||
name(notaryNames[replicaId])
|
||||
notary(NotaryConfig(validating = false, serviceLegalName = clusterName, bftSMaRt = BFTSMaRtConfiguration(replicaId, clusterAddresses)))
|
||||
extraConfig = mapOf("h2Settings" to mapOf("address" to "localhost:0"))
|
||||
configure()
|
||||
}
|
||||
notaryNode(0) {
|
||||
|
@ -33,6 +33,7 @@ class CustomNotaryCordform : CordformDefinition() {
|
||||
}
|
||||
rpcUsers(notaryDemoUser)
|
||||
devMode(true)
|
||||
extraConfig = mapOf("h2Settings" to mapOf("address" to "localhost:0"))
|
||||
}
|
||||
node {
|
||||
name(BOB_NAME)
|
||||
@ -42,6 +43,7 @@ class CustomNotaryCordform : CordformDefinition() {
|
||||
adminAddress("localhost:10106")
|
||||
}
|
||||
devMode(true)
|
||||
extraConfig = mapOf("h2Settings" to mapOf("address" to "localhost:0"))
|
||||
}
|
||||
node {
|
||||
name(DUMMY_NOTARY_NAME)
|
||||
@ -52,6 +54,7 @@ class CustomNotaryCordform : CordformDefinition() {
|
||||
}
|
||||
notary(NotaryConfig(validating = true, custom = true))
|
||||
devMode(true)
|
||||
extraConfig = mapOf("h2Settings" to mapOf("address" to "localhost:0"))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -45,6 +45,7 @@ class RaftNotaryCordform : CordformDefinition() {
|
||||
}
|
||||
rpcUsers(notaryDemoUser)
|
||||
devMode(true)
|
||||
extraConfig = mapOf("h2Settings" to mapOf("address" to "localhost:0"))
|
||||
}
|
||||
node {
|
||||
name(BOB_NAME)
|
||||
@ -54,11 +55,13 @@ class RaftNotaryCordform : CordformDefinition() {
|
||||
adminAddress("localhost:10106")
|
||||
}
|
||||
devMode(true)
|
||||
extraConfig = mapOf("h2Settings" to mapOf("address" to "localhost:0"))
|
||||
}
|
||||
fun notaryNode(index: Int, nodePort: Int, clusterPort: Int? = null, configure: CordformNode.() -> Unit) = node {
|
||||
name(notaryNames[index])
|
||||
val clusterAddresses = if (clusterPort != null) listOf(NetworkHostAndPort("localhost", clusterPort)) else emptyList()
|
||||
notary(NotaryConfig(validating = true, serviceLegalName = clusterName, raft = RaftConfig(NetworkHostAndPort("localhost", nodePort), clusterAddresses)))
|
||||
extraConfig = mapOf("h2Settings" to mapOf("address" to "localhost:0"))
|
||||
configure()
|
||||
devMode(true)
|
||||
}
|
||||
|
@ -39,6 +39,7 @@ class SingleNotaryCordform : CordformDefinition() {
|
||||
}
|
||||
rpcUsers(notaryDemoUser)
|
||||
devMode(true)
|
||||
extraConfig = mapOf("h2Settings" to mapOf("address" to "localhost:0"))
|
||||
}
|
||||
node {
|
||||
name(BOB_NAME)
|
||||
@ -48,6 +49,7 @@ class SingleNotaryCordform : CordformDefinition() {
|
||||
adminAddress("localhost:10106")
|
||||
}
|
||||
devMode(true)
|
||||
extraConfig = mapOf("h2Settings" to mapOf("address" to "localhost:0"))
|
||||
}
|
||||
node {
|
||||
name(DUMMY_NOTARY_NAME)
|
||||
@ -58,6 +60,7 @@ class SingleNotaryCordform : CordformDefinition() {
|
||||
}
|
||||
notary(NotaryConfig(validating = true))
|
||||
devMode(true)
|
||||
extraConfig = mapOf("h2Settings" to mapOf("address" to "localhost:0"))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -104,7 +104,8 @@ task deployNodes(type: net.corda.plugins.Cordform, dependsOn: ['jar']) {
|
||||
extraConfig = [
|
||||
custom: [
|
||||
jvmArgs: ["-Xmx1g"]
|
||||
]
|
||||
],
|
||||
'h2Settings.address' : 'localhost:10038'
|
||||
]
|
||||
}
|
||||
node {
|
||||
@ -122,7 +123,8 @@ task deployNodes(type: net.corda.plugins.Cordform, dependsOn: ['jar']) {
|
||||
extraConfig = [
|
||||
custom: [
|
||||
jvmArgs: ["-Xmx1g"]
|
||||
]
|
||||
],
|
||||
'h2Settings.address' : 'localhost:10039'
|
||||
]
|
||||
}
|
||||
node {
|
||||
@ -140,7 +142,8 @@ task deployNodes(type: net.corda.plugins.Cordform, dependsOn: ['jar']) {
|
||||
extraConfig = [
|
||||
custom: [
|
||||
jvmArgs: ["-Xmx1g"]
|
||||
]
|
||||
],
|
||||
'h2Settings.address' : 'localhost:10040'
|
||||
]
|
||||
}
|
||||
node {
|
||||
@ -158,7 +161,8 @@ task deployNodes(type: net.corda.plugins.Cordform, dependsOn: ['jar']) {
|
||||
extraConfig = [
|
||||
custom: [
|
||||
jvmArgs: ["-Xmx1g"]
|
||||
]
|
||||
],
|
||||
'h2Settings.address' : 'localhost:10041'
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@ -64,6 +64,7 @@ task deployNodes(type: net.corda.plugins.Cordform, dependsOn: ['jar']) {
|
||||
address "localhost:10003"
|
||||
adminAddress "localhost:10004"
|
||||
}
|
||||
extraConfig = ['h2Settings.address' : 'localhost:10014']
|
||||
cordapps = ["$project.group:finance:$corda_release_version"]
|
||||
}
|
||||
node {
|
||||
@ -75,6 +76,7 @@ task deployNodes(type: net.corda.plugins.Cordform, dependsOn: ['jar']) {
|
||||
address "localhost:10006"
|
||||
adminAddress "localhost:10007"
|
||||
}
|
||||
extraConfig = ['h2Settings.address' : 'localhost:10015']
|
||||
}
|
||||
node {
|
||||
name "O=Bank B,L=New York,C=US"
|
||||
@ -85,6 +87,7 @@ task deployNodes(type: net.corda.plugins.Cordform, dependsOn: ['jar']) {
|
||||
address "localhost:10009"
|
||||
adminAddress "localhost:10010"
|
||||
}
|
||||
extraConfig = ['h2Settings.address' : 'localhost:10016']
|
||||
}
|
||||
node {
|
||||
name "O=BankOfCorda,L=New York,C=US"
|
||||
@ -95,6 +98,7 @@ task deployNodes(type: net.corda.plugins.Cordform, dependsOn: ['jar']) {
|
||||
address "localhost:10012"
|
||||
adminAddress "localhost:10013"
|
||||
}
|
||||
extraConfig = ['h2Settings.address' : 'localhost:10017']
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -138,6 +138,18 @@ class UnstartedMockNode private constructor(private val node: InternalMockNetwor
|
||||
* @return A [StartedMockNode] object.
|
||||
*/
|
||||
fun start(): StartedMockNode = StartedMockNode.create(node.start())
|
||||
|
||||
/**
|
||||
* A [StartedMockNode] object for this running node.
|
||||
* @throws [IllegalStateException] if the node is not running yet.
|
||||
*/
|
||||
val started: StartedMockNode
|
||||
get() = StartedMockNode.create(node.started ?: throw IllegalStateException("Node ID=$id is not running"))
|
||||
|
||||
/**
|
||||
* Whether this node has been started yet.
|
||||
*/
|
||||
val isStarted: Boolean get() = node.started != null
|
||||
}
|
||||
|
||||
/** A class that represents a started mock node for testing. */
|
||||
@ -421,7 +433,7 @@ open class MockNetwork(
|
||||
forcedID: Int? = null,
|
||||
entropyRoot: BigInteger = BigInteger.valueOf(random63BitValue()),
|
||||
configOverrides: (NodeConfiguration) -> Any? = {},
|
||||
additionalCordapps: Set<TestCorDapp> = emptySet()): UnstartedMockNode {
|
||||
additionalCordapps: Set<TestCorDapp>): UnstartedMockNode {
|
||||
val parameters = MockNodeParameters(forcedID, legalName, entropyRoot, configOverrides, additionalCordapps)
|
||||
return UnstartedMockNode.create(internalMockNetwork.createUnstartedNode(InternalMockNodeParameters(parameters)))
|
||||
}
|
||||
|
@ -157,7 +157,7 @@ open class InternalMockNetwork(defaultParameters: MockNetworkParameters = MockNe
|
||||
val testDirectory: Path = Paths.get("build", getTimestampAsDirectoryName()),
|
||||
val networkParameters: NetworkParameters = testNetworkParameters(),
|
||||
val defaultFactory: (MockNodeArgs, CordappLoader?) -> MockNode = { args, cordappLoader -> cordappLoader?.let { MockNode(args, it) } ?: MockNode(args) },
|
||||
val cordappsForAllNodes: Set<TestCorDapp> = emptySet()) {
|
||||
val cordappsForAllNodes: Set<TestCorDapp> = emptySet()) : AutoCloseable {
|
||||
init {
|
||||
// Apache SSHD for whatever reason registers a SFTP FileSystemProvider - which gets loaded by JimFS.
|
||||
// This SFTP support loads BouncyCastle, which we want to avoid.
|
||||
@ -347,7 +347,7 @@ open class InternalMockNetwork(defaultParameters: MockNetworkParameters = MockNe
|
||||
}
|
||||
}
|
||||
|
||||
override val started: TestStartedNode? get() = uncheckedCast(super.started)
|
||||
override val started: TestStartedNode? get() = super.started
|
||||
|
||||
override fun createStartedNode(nodeInfo: NodeInfo, rpcOps: CordaRPCOps, notaryService: NotaryService?): TestStartedNode {
|
||||
return TestStartedNodeImpl(
|
||||
@ -562,6 +562,8 @@ open class InternalMockNetwork(defaultParameters: MockNetworkParameters = MockNe
|
||||
fun waitQuiescent() {
|
||||
busyLatch.await()
|
||||
}
|
||||
|
||||
override fun close() = stopNodes()
|
||||
}
|
||||
|
||||
abstract class MessagingServiceSpy {
|
||||
|
@ -0,0 +1,47 @@
|
||||
package net.corda.testing.node
|
||||
|
||||
import net.corda.testing.core.*
|
||||
import org.assertj.core.api.Assertions.*
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
class MockNetworkTest {
|
||||
private companion object {
|
||||
private const val NODE_ID = 101
|
||||
}
|
||||
private lateinit var mockNetwork: MockNetwork
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
mockNetwork = MockNetwork(cordappPackages = emptyList())
|
||||
}
|
||||
|
||||
@After
|
||||
fun done() {
|
||||
mockNetwork.stopNodes()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `with a started node`() {
|
||||
val unstarted = mockNetwork.createUnstartedNode(DUMMY_BANK_A_NAME, forcedID = NODE_ID)
|
||||
assertFalse(unstarted.isStarted)
|
||||
|
||||
mockNetwork.startNodes()
|
||||
assertTrue(unstarted.isStarted)
|
||||
|
||||
val started = unstarted.started
|
||||
assertEquals(NODE_ID, started.id)
|
||||
assertEquals(DUMMY_BANK_A_NAME, started.info.identityFromX500Name(DUMMY_BANK_A_NAME).name)
|
||||
assertFailsWith<IllegalArgumentException> { started.info.identityFromX500Name(DUMMY_BANK_B_NAME) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `with an unstarted node`() {
|
||||
val unstarted = mockNetwork.createUnstartedNode(DUMMY_BANK_A_NAME, forcedID = NODE_ID)
|
||||
val ex = assertFailsWith<IllegalStateException> { unstarted.started }
|
||||
assertThat(ex).hasMessage("Node ID=$NODE_ID is not running")
|
||||
}
|
||||
}
|
@ -34,6 +34,7 @@ dependencies {
|
||||
compile 'com.nhaarman:mockito-kotlin:1.5.0'
|
||||
compile "org.mockito:mockito-core:$mockito_version"
|
||||
compile "org.assertj:assertj-core:$assertj_version"
|
||||
compile "com.natpryce:hamkrest:$hamkrest_version"
|
||||
|
||||
// Guava: Google test library (collections test suite)
|
||||
compile "com.google.guava:guava-testlib:$guava_version"
|
||||
|
@ -35,6 +35,7 @@ import java.math.BigInteger
|
||||
import java.security.KeyPair
|
||||
import java.security.PublicKey
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.*
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
@ -118,6 +119,18 @@ fun getTestPartyAndCertificate(name: CordaX500Name, publicKey: PublicKey): Party
|
||||
return getTestPartyAndCertificate(Party(name, publicKey))
|
||||
}
|
||||
|
||||
|
||||
private val count = AtomicInteger(0)
|
||||
/**
|
||||
* Randomise a party name to avoid clashes with other tests
|
||||
*/
|
||||
fun makeUnique(name: CordaX500Name) = name.copy(commonName =
|
||||
if (name.commonName == null) {
|
||||
count.incrementAndGet().toString()
|
||||
} else {
|
||||
"${ name.commonName }_${ count.incrementAndGet() }"
|
||||
})
|
||||
|
||||
/**
|
||||
* A class that encapsulates a test identity containing a [CordaX500Name] and a [KeyPair], alongside a range
|
||||
* of utility methods for use during testing.
|
||||
|
@ -298,7 +298,7 @@ data class TestLedgerDSLInterpreter private constructor(
|
||||
copy().dsl()
|
||||
|
||||
override fun attachment(attachment: InputStream): SecureHash {
|
||||
return services.attachments.importAttachment(attachment, UNKNOWN_UPLOADER, null)
|
||||
return services.attachments.importAttachment(attachment, "TestDSL", null)
|
||||
}
|
||||
|
||||
override fun verifies(): EnforceVerifyOrFail {
|
||||
|
@ -13,12 +13,11 @@ package net.corda.testing.internal
|
||||
import net.corda.core.contracts.ContractClassName
|
||||
import net.corda.core.cordapp.Cordapp
|
||||
import net.corda.core.crypto.SecureHash
|
||||
import net.corda.core.internal.TEST_UPLOADER
|
||||
import net.corda.core.internal.DEPLOYED_CORDAPP_UPLOADER
|
||||
import net.corda.core.internal.cordapp.CordappImpl
|
||||
import net.corda.core.node.services.AttachmentId
|
||||
import net.corda.core.node.services.AttachmentStorage
|
||||
import net.corda.node.cordapp.CordappLoader
|
||||
import net.corda.node.internal.cordapp.JarScanningCordappLoader
|
||||
import net.corda.node.internal.cordapp.CordappProviderImpl
|
||||
import net.corda.testing.services.MockAttachmentStorage
|
||||
import java.nio.file.Paths
|
||||
@ -60,7 +59,7 @@ class MockCordappProvider(
|
||||
return if (!existingAttachment.isEmpty()) {
|
||||
existingAttachment.keys.first()
|
||||
} else {
|
||||
attachments.importContractAttachment(contractClassNames, TEST_UPLOADER, data.inputStream())
|
||||
attachments.importContractAttachment(contractClassNames, DEPLOYED_CORDAPP_UPLOADER, data.inputStream())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,99 @@
|
||||
package net.corda.testing.internal.matchers
|
||||
|
||||
import com.natpryce.hamkrest.*
|
||||
|
||||
internal fun indent(description: String) = description.lineSequence().map { "\t$it" }.joinToString("\n")
|
||||
|
||||
fun hasEntrySetSize(expected: Int) = object : Matcher<Map<*, *>> {
|
||||
override val description = "is a map of size $expected"
|
||||
override fun invoke(actual: Map<*, *>) =
|
||||
if (actual.size == expected) {
|
||||
MatchResult.Match
|
||||
} else {
|
||||
MatchResult.Mismatch("was a map of size ${actual.size}")
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> Matcher<T>.redescribe(redescriber: (String) -> String) = object : Matcher<T> {
|
||||
override val description = redescriber(this@redescribe.description)
|
||||
override fun invoke(actual: T) = this@redescribe(actual)
|
||||
}
|
||||
|
||||
fun <T> Matcher<T>.redescribeMismatch(redescriber: (String) -> String) = object : Matcher<T> {
|
||||
override val description = this@redescribeMismatch.description
|
||||
override fun invoke(actual: T) = this@redescribeMismatch(actual).modifyMismatchDescription(redescriber)
|
||||
}
|
||||
|
||||
fun MatchResult.modifyMismatchDescription(modify: (String) -> String) = when(this) {
|
||||
is MatchResult.Match -> MatchResult.Match
|
||||
is MatchResult.Mismatch -> MatchResult.Mismatch(modify(this.description))
|
||||
}
|
||||
|
||||
fun <O, I> Matcher<I>.extrude(projection: (O) -> I) = object : Matcher<O> {
|
||||
override val description = this@extrude.description
|
||||
override fun invoke(actual: O) = this@extrude(projection(actual))
|
||||
}
|
||||
|
||||
internal fun <K, V> hasAnEntry(key: K, valueMatcher: Matcher<V>) = object : Matcher<Map<K, V>> {
|
||||
override val description = "$key: ${valueMatcher.description}"
|
||||
override fun invoke(actual: Map<K, V>): MatchResult =
|
||||
actual[key]?.let { valueMatcher(it) }?.let { when(it) {
|
||||
is MatchResult.Match -> it
|
||||
is MatchResult.Mismatch -> MatchResult.Mismatch("$key: ${it.description}")
|
||||
}} ?: MatchResult.Mismatch("$key was not present")
|
||||
}
|
||||
|
||||
fun <K, V> hasEntry(key: K, valueMatcher: Matcher<V>) =
|
||||
hasAnEntry(key, valueMatcher).redescribe { "Is a map containing the entry:\n${indent(it)}"}
|
||||
|
||||
fun <K, V> hasOnlyEntries(vararg entryMatchers: Pair<K, Matcher<V>>) = hasOnlyEntries(entryMatchers.toList())
|
||||
|
||||
fun <K, V> hasOnlyEntries(entryMatchers: Collection<Pair<K, Matcher<V>>>) =
|
||||
allOf(
|
||||
hasEntrySetSize(entryMatchers.size),
|
||||
hasEntries(entryMatchers)
|
||||
)
|
||||
|
||||
fun <K, V> hasEntries(vararg entryMatchers: Pair<K, Matcher<V>>) = hasEntries(entryMatchers.toList())
|
||||
|
||||
fun <K, V> hasEntries(entryMatchers: Collection<Pair<K, Matcher<V>>>) = object : Matcher<Map<K, V>> {
|
||||
override val description =
|
||||
"is a map containing the entries:\n" +
|
||||
entryMatchers.asSequence()
|
||||
.joinToString("\n") { indent("${it.first}: ${it.second.description}") }
|
||||
|
||||
override fun invoke(actual: Map<K, V>): MatchResult {
|
||||
val mismatches = entryMatchers.map { hasAnEntry(it.first, it.second)(actual) }
|
||||
.filterIsInstance<MatchResult.Mismatch>()
|
||||
|
||||
return if (mismatches.isEmpty()) {
|
||||
MatchResult.Match
|
||||
} else {
|
||||
MatchResult.Mismatch(
|
||||
"had entries which did not meet criteria:\n" +
|
||||
mismatches.joinToString("\n") { indent(it.description) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> allOf(vararg matchers: Matcher<T>) = allOf(matchers.toList())
|
||||
|
||||
fun <T> allOf(matchers: Collection<Matcher<T>>) = object : Matcher<T> {
|
||||
override val description =
|
||||
"meets all of the criteria:\n" +
|
||||
matchers.asSequence()
|
||||
.joinToString("\n") { indent(it.description) }
|
||||
|
||||
override fun invoke(actual: T) : MatchResult {
|
||||
val mismatches = matchers.map { it(actual) }
|
||||
.filterIsInstance<MatchResult.Mismatch>()
|
||||
|
||||
return if (mismatches.isEmpty()) {
|
||||
MatchResult.Match
|
||||
} else {
|
||||
MatchResult.Mismatch(
|
||||
"did not meet criteria:\n" +
|
||||
mismatches.joinToString("\n") { indent(it.description) })
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,38 @@
|
||||
package net.corda.testing.internal.matchers.flow
|
||||
|
||||
import com.natpryce.hamkrest.Matcher
|
||||
import com.natpryce.hamkrest.equalTo
|
||||
import net.corda.core.internal.FlowStateMachine
|
||||
import net.corda.testing.internal.matchers.*
|
||||
|
||||
/**
|
||||
* Matches a Flow that succeeds with a result matched by the given matcher
|
||||
*/
|
||||
fun <T> willReturn(): Matcher<FlowStateMachine<T>> = net.corda.testing.internal.matchers.future.willReturn<T>()
|
||||
.extrude(FlowStateMachine<T>::resultFuture)
|
||||
.redescribe { "is a flow that will return" }
|
||||
|
||||
fun <T> willReturn(expected: T): Matcher<FlowStateMachine<T>> = willReturn(equalTo(expected))
|
||||
|
||||
/**
|
||||
* Matches a Flow that succeeds with a result matched by the given matcher
|
||||
*/
|
||||
fun <T> willReturn(successMatcher: Matcher<T>) = net.corda.testing.internal.matchers.future.willReturn(successMatcher)
|
||||
.extrude(FlowStateMachine<out T>::resultFuture)
|
||||
.redescribe { "is a flow that will return with a value that ${successMatcher.description}" }
|
||||
|
||||
/**
|
||||
* Matches a Flow that fails, with an exception matched by the given matcher.
|
||||
*/
|
||||
inline fun <reified E: Exception> willThrow(failureMatcher: Matcher<E>) =
|
||||
net.corda.testing.internal.matchers.future.willThrow(failureMatcher)
|
||||
.extrude(FlowStateMachine<*>::resultFuture)
|
||||
.redescribe { "is a flow that will fail, throwing an exception that ${failureMatcher.description}" }
|
||||
|
||||
/**
|
||||
* Matches a Flow that fails, with an exception of the specified type.
|
||||
*/
|
||||
inline fun <reified E: Exception> willThrow() =
|
||||
net.corda.testing.internal.matchers.future.willThrow<E>()
|
||||
.extrude(FlowStateMachine<*>::resultFuture)
|
||||
.redescribe { "is a flow that will fail with an exception of type ${E::class.java.simpleName}" }
|
@ -1,9 +1,10 @@
|
||||
package net.corda.core.flows.matchers
|
||||
package net.corda.testing.internal.matchers.future
|
||||
|
||||
import com.natpryce.hamkrest.MatchResult
|
||||
import com.natpryce.hamkrest.Matcher
|
||||
import com.natpryce.hamkrest.equalTo
|
||||
import net.corda.core.utilities.getOrThrow
|
||||
import net.corda.testing.internal.matchers.modifyMismatchDescription
|
||||
import java.util.concurrent.Future
|
||||
|
||||
/**
|
||||
@ -16,7 +17,7 @@ fun <T> willReturn() = object : Matcher<Future<T>> {
|
||||
actual.getOrThrow()
|
||||
MatchResult.Match
|
||||
} catch (e: Exception) {
|
||||
MatchResult.Mismatch("Failed with $e")
|
||||
MatchResult.Mismatch("failed with $e")
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,9 +30,9 @@ fun <T> willReturn(successMatcher: Matcher<T>) = object : Matcher<Future<out T>>
|
||||
override val description: String = "is a future that will succeed with a value that ${successMatcher.description}"
|
||||
|
||||
override fun invoke(actual: Future<out T>): MatchResult = try {
|
||||
successMatcher(actual.getOrThrow())
|
||||
successMatcher(actual.getOrThrow()).modifyMismatchDescription { "succeeded with value that $it" }
|
||||
} catch (e: Exception) {
|
||||
MatchResult.Mismatch("Failed with $e")
|
||||
MatchResult.Mismatch("failed with $e")
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,11 +45,11 @@ inline fun <reified E: Exception> willThrow(failureMatcher: Matcher<E>) = object
|
||||
|
||||
override fun invoke(actual: Future<*>): MatchResult = try {
|
||||
actual.getOrThrow()
|
||||
MatchResult.Mismatch("Succeeded")
|
||||
MatchResult.Mismatch("succeeded")
|
||||
} catch (e: Exception) {
|
||||
when(e) {
|
||||
is E -> failureMatcher(e)
|
||||
else -> MatchResult.Mismatch("Failure class was ${e.javaClass}")
|
||||
is E -> failureMatcher(e).modifyMismatchDescription { "failed with ${E::class.java.simpleName} that $it" }
|
||||
else -> MatchResult.Mismatch("failed with ${e.javaClass}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -62,11 +63,11 @@ inline fun <reified E: Exception> willThrow() = object : Matcher<Future<*>> {
|
||||
|
||||
override fun invoke(actual: Future<*>): MatchResult = try {
|
||||
actual.getOrThrow()
|
||||
MatchResult.Mismatch("Succeeded")
|
||||
MatchResult.Mismatch("succeeded")
|
||||
} catch (e: Exception) {
|
||||
when(e) {
|
||||
is E -> MatchResult.Match
|
||||
else -> MatchResult.Mismatch("Failure class was ${e.javaClass}")
|
||||
else -> MatchResult.Mismatch("failed with ${e.javaClass}")
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
package net.corda.testing.internal.matchers.rpc
|
||||
|
||||
import com.natpryce.hamkrest.Matcher
|
||||
import net.corda.core.messaging.FlowHandle
|
||||
import net.corda.testing.internal.matchers.extrude
|
||||
import net.corda.testing.internal.matchers.redescribe
|
||||
|
||||
/**
|
||||
* Matches a flow handle that succeeds with a result matched by the given matcher
|
||||
*/
|
||||
fun <T> willReturn() = net.corda.testing.internal.matchers.future.willReturn<T>()
|
||||
.extrude(FlowHandle<T>::returnValue)
|
||||
.redescribe { "is an RPG flow handle that will return" }
|
||||
|
||||
/**
|
||||
* Matches a flow handle that succeeds with a result matched by the given matcher
|
||||
*/
|
||||
fun <T> willReturn(successMatcher: Matcher<T>) = net.corda.testing.internal.matchers.future.willReturn(successMatcher)
|
||||
.extrude(FlowHandle<T>::returnValue)
|
||||
.redescribe { "is an RPG flow handle that will return a value that ${successMatcher.description}" }
|
||||
|
||||
/**
|
||||
* Matches a flow handle that fails, with an exception matched by the given matcher.
|
||||
*/
|
||||
inline fun <reified E: Exception> willThrow(failureMatcher: Matcher<E>) =
|
||||
net.corda.testing.internal.matchers.future.willThrow(failureMatcher)
|
||||
.extrude(FlowHandle<*>::returnValue)
|
||||
.redescribe { "is an RPG flow handle that will fail with an exception that ${failureMatcher.description}" }
|
||||
|
||||
/**
|
||||
* Matches a flow handle that fails, with an exception of the specified type.
|
||||
*/
|
||||
inline fun <reified E: Exception> willThrow() =
|
||||
net.corda.testing.internal.matchers.future.willThrow<E>()
|
||||
.extrude(FlowHandle<*>::returnValue)
|
||||
.redescribe { "is an RPG flow handle that will fail with an exception of type ${E::class.java.simpleName}" }
|
@ -0,0 +1,58 @@
|
||||
package net.corda.testing.internal
|
||||
|
||||
import com.natpryce.hamkrest.MatchResult
|
||||
import com.natpryce.hamkrest.equalTo
|
||||
import net.corda.testing.internal.matchers.hasEntries
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class MatcherTests {
|
||||
@Test
|
||||
fun `nested items indent`() {
|
||||
val nestedMap = mapOf(
|
||||
"a" to mapOf(
|
||||
"apple" to "vegetable",
|
||||
"aardvark" to "animal",
|
||||
"anthracite" to "mineral"),
|
||||
"b" to mapOf(
|
||||
"broccoli" to "mineral",
|
||||
"bison" to "animal",
|
||||
"bauxite" to "vegetable")
|
||||
)
|
||||
|
||||
val matcher = hasEntries(
|
||||
"a" to hasEntries(
|
||||
"aardvark" to equalTo("animal"),
|
||||
"anthracite" to equalTo("mineral")
|
||||
),
|
||||
"b" to hasEntries(
|
||||
"bison" to equalTo("animal"),
|
||||
"bauxite" to equalTo("mineral")
|
||||
)
|
||||
)
|
||||
|
||||
println(matcher.description)
|
||||
println((matcher(nestedMap) as MatchResult.Mismatch).description)
|
||||
|
||||
assertEquals(
|
||||
"""
|
||||
is a map containing the entries:
|
||||
a: is a map containing the entries:
|
||||
aardvark: is equal to "animal"
|
||||
anthracite: is equal to "mineral"
|
||||
b: is a map containing the entries:
|
||||
bison: is equal to "animal"
|
||||
bauxite: is equal to "mineral"
|
||||
""".trimIndent().replace(" ", "\t"),
|
||||
matcher.description)
|
||||
|
||||
assertEquals(
|
||||
"""
|
||||
had entries which did not meet criteria:
|
||||
b: had entries which did not meet criteria:
|
||||
bauxite: was: "vegetable"
|
||||
""".trimIndent().replace(" ", "\t"),
|
||||
(matcher(nestedMap) as MatchResult.Mismatch).description
|
||||
)
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user