From 4c9d1e541642773045691a5f34b032814ce9d500 Mon Sep 17 00:00:00 2001 From: Ronald Holshausen Date: Mon, 4 May 2026 11:18:47 +1000 Subject: [PATCH 1/5] feat: Add DSL method to capture an external reference agaist a V4 Pact interaction --- consumer/http_v4.go | 9 +++++++++ consumer/http_v4_test.go | 18 ++++++++++++++++++ internal/native/message_server.go | 16 ++++++++++++++++ internal/native/mock_server.go | 16 ++++++++++++++++ internal/native/pact.h | 10 ++++++++++ message/v4/asynchronous_message.go | 9 +++++++++ message/v4/asynchronous_message_test.go | 19 +++++++++++++++++++ message/v4/synchronous_message.go | 9 +++++++++ message/v4/synchronous_message_test.go | 21 +++++++++++++++++++++ 9 files changed, 127 insertions(+) diff --git a/consumer/http_v4.go b/consumer/http_v4.go index 8dd986b79..b7e782e50 100644 --- a/consumer/http_v4.go +++ b/consumer/http_v4.go @@ -92,6 +92,15 @@ func (i *V4UnconfiguredInteraction) UponReceiving(description string) *V4Unconfi return i } +// AddExternalReference records a reference to an external resource (such as a ticket or +// pull request) against the interaction. References appear under +// comments.references[group][name] in the Pact file. May be called multiple times. +func (i *V4UnconfiguredInteraction) AddExternalReference(group, name, value string) *V4UnconfiguredInteraction { + i.interaction.interaction.AddInteractionReference(group, name, value) + + return i +} + // WithRequest provides a builder for the expected request func (i *V4UnconfiguredInteraction) WithCompleteRequest(request Request) *V4InteractionWithCompleteRequest { i.interaction.WithCompleteRequest(request) diff --git a/consumer/http_v4_test.go b/consumer/http_v4_test.go index 33165496b..1728a1fa0 100644 --- a/consumer/http_v4_test.go +++ b/consumer/http_v4_test.go @@ -85,6 +85,24 @@ func TestHttpV4TypeSystem(t *testing.T) { } +func TestV4HTTPAddExternalReference(t *testing.T) { + p, err := NewV4Pact(MockHTTPProviderConfig{ + Consumer: "consumer", + Provider: "provider", + }) + assert.NoError(t, err) + + err = p.AddInteraction(). + UponReceiving("a request with an external reference"). + AddExternalReference("Jira", "TICKET-123", "https://jira.example.com/browse/TICKET-123"). + WithRequest("GET", "/", func(b *V4RequestBuilder) {}). + WillRespondWith(200, func(b *V4ResponseBuilder) {}). + ExecuteTest(t, func(msc MockServerConfig) error { + return nil + }) + assert.NoError(t, err) +} + var Like = matchers.Like var EachLike = matchers.EachLike var Term = matchers.Term diff --git a/internal/native/message_server.go b/internal/native/message_server.go index bd6e6ec3a..52af8526b 100644 --- a/internal/native/message_server.go +++ b/internal/native/message_server.go @@ -596,3 +596,19 @@ func (m *MessageServer) WritePactFileForServer(port int, dir string, overwrite b return fmt.Errorf("an unknown error ocurred when writing to pact file") } } + +// AddInteractionReference records an external reference (e.g. a ticket or pull request) +// against the interaction. References are stored under comments.references[group][name] +// in the Pact file. This is a V4-only feature. +func (m *Message) AddInteractionReference(group, name, value string) *Message { + cGroup := C.CString(group) + defer free(cGroup) + cName := C.CString(name) + defer free(cName) + cValue := C.CString(value) + defer free(cValue) + + C.pactffi_add_interaction_reference(m.handle, cGroup, cName, cValue) + + return m +} diff --git a/internal/native/mock_server.go b/internal/native/mock_server.go index 38ab72d36..da383d0ec 100644 --- a/internal/native/mock_server.go +++ b/internal/native/mock_server.go @@ -643,6 +643,22 @@ func (i *Interaction) WithStatus(status int) *Interaction { return i } +// AddInteractionReference records an external reference (e.g. a ticket or pull request) +// against the interaction. References are stored under comments.references[group][name] +// in the Pact file. This is a V4-only feature. +func (i *Interaction) AddInteractionReference(group, name, value string) *Interaction { + cGroup := C.CString(group) + defer free(cGroup) + cName := C.CString(name) + defer free(cName) + cValue := C.CString(value) + defer free(cValue) + + C.pactffi_add_interaction_reference(i.handle, cGroup, cName, cValue) + + return i +} + type stringLike interface { String() string } diff --git a/internal/native/pact.h b/internal/native/pact.h index c5865d040..df2d4e7a1 100644 --- a/internal/native/pact.h +++ b/internal/native/pact.h @@ -4018,6 +4018,16 @@ bool pactffi_set_comment(InteractionHandle interaction, const char *key, const c */ bool pactffi_add_text_comment(InteractionHandle interaction, const char *comment); +/** + * Add an external reference to the interaction. + * + * References are stored under `comments.references[group][name]` in the Pact file. + * The group, name and value parameters must be valid UTF-8 null-terminated strings. + * + * Returns false if the reference could not be added (e.g. invalid handle or NULL strings). + */ +bool pactffi_add_interaction_reference(InteractionHandle interaction, const char *group, const char *name, const char *value); + /** * Get an iterator over all the messages of the Pact. The returned iterator needs to be * freed with `pactffi_pact_message_iter_delete`. diff --git a/message/v4/asynchronous_message.go b/message/v4/asynchronous_message.go index 76bfa13a2..21d1d236e 100644 --- a/message/v4/asynchronous_message.go +++ b/message/v4/asynchronous_message.go @@ -47,6 +47,15 @@ func (m *AsynchronousMessageBuilder) GivenWithParameter(state models.ProviderSta return m } +// AddExternalReference records a reference to an external resource (such as a ticket or +// pull request) against the interaction. References appear under +// comments.references[group][name] in the Pact file. May be called multiple times. +func (m *AsynchronousMessageBuilder) AddExternalReference(group, name, value string) *AsynchronousMessageBuilder { + m.messageHandle.AddInteractionReference(group, name, value) + + return m +} + // ExpectsToReceive specifies the content it is expecting to be // given from the Provider. The function must be able to handle this // message for the interaction to succeed. diff --git a/message/v4/asynchronous_message_test.go b/message/v4/asynchronous_message_test.go index e01114825..57a89a81f 100644 --- a/message/v4/asynchronous_message_test.go +++ b/message/v4/asynchronous_message_test.go @@ -40,6 +40,25 @@ func TestAsyncTypeSystem(t *testing.T) { } +func TestAsyncAddExternalReference(t *testing.T) { + p, _ := NewAsynchronousPact(Config{ + Consumer: "asyncconsumer", + Provider: "asyncprovider", + PactDir: "/tmp/", + }) + + err := p.AddAsynchronousMessage(). + AddExternalReference("GitHub", "PR-456", "https://github.com/org/repo/pull/456"). + ExpectsToReceive("a message with an external reference"). + WithJSONContent(map[string]string{"event": "user.created"}). + ConsumedBy(func(mc AsynchronousMessage) error { + return nil + }). + Verify(t) + + assert.NoError(t, err) +} + // Sync - with plugin, but no transport // TODO: ExecuteTest has been disabled for now, because it's not very useful func TestAsyncTypeSystem_CsvPlugin_Matcher(t *testing.T) { diff --git a/message/v4/synchronous_message.go b/message/v4/synchronous_message.go index d5c350074..03c1eaca0 100644 --- a/message/v4/synchronous_message.go +++ b/message/v4/synchronous_message.go @@ -63,6 +63,15 @@ type UnconfiguredSynchronousMessageBuilder struct { pact *SynchronousPact } +// AddExternalReference records a reference to an external resource (such as a ticket or +// pull request) against the interaction. References appear under +// comments.references[group][name] in the Pact file. May be called multiple times. +func (m *UnconfiguredSynchronousMessageBuilder) AddExternalReference(group, name, value string) *UnconfiguredSynchronousMessageBuilder { + m.messageHandle.AddInteractionReference(group, name, value) + + return m +} + // UsingPlugin enables a plugin for use in the current test case func (m *UnconfiguredSynchronousMessageBuilder) UsingPlugin(config PluginConfig) *SynchronousMessageWithPlugin { err := m.pact.mockserver.UsingPlugin(config.Plugin, config.Version) diff --git a/message/v4/synchronous_message_test.go b/message/v4/synchronous_message_test.go index 3b1d4ac24..53765610e 100644 --- a/message/v4/synchronous_message_test.go +++ b/message/v4/synchronous_message_test.go @@ -44,6 +44,27 @@ func TestSyncTypeSystem_NoPlugin(t *testing.T) { assert.NoError(t, err) } +func TestSyncAddExternalReference(t *testing.T) { + p, _ := NewSynchronousPact(Config{ + Consumer: "consumer", + Provider: "provider", + }) + + err := p.AddSynchronousMessage("a sync message with an external reference"). + AddExternalReference("Jira", "TICKET-789", "https://jira.example.com/browse/TICKET-789"). + WithRequest(func(r *SynchronousMessageWithRequestBuilder) { + r.WithJSONContent(map[string]string{"request": "ping"}) + }). + WithResponse(func(r *SynchronousMessageWithResponseBuilder) { + r.WithJSONContent(map[string]string{"response": "pong"}) + }). + ExecuteTest(t, func(m SynchronousMessage) error { + return nil + }) + + assert.NoError(t, err) +} + // Sync - with plugin, but no transport func TestSyncTypeSystem_CsvPlugin_Matcher(t *testing.T) { p, _ := NewSynchronousPact(Config{ From b3c9231a875ce17bb4aff97c00300009be40409e Mon Sep 17 00:00:00 2001 From: Ronald Holshausen Date: Mon, 4 May 2026 11:50:00 +1000 Subject: [PATCH 2/5] fix: Update the installer to fetch FFI 0.5.4 --- installer/installer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/installer/installer.go b/installer/installer.go index 30965e1fa..89eb9fddd 100644 --- a/installer/installer.go +++ b/installer/installer.go @@ -388,7 +388,7 @@ const ( var packages = map[string]packageInfo{ FFIPackage: { libName: "libpact_ffi", - version: "0.4.28", + version: "0.5.4", semverRange: ">= 0.4.0, < 1.0.0", }, } From 3a66bff3f8054505d91e5d6b7958be63b2f084d2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 08:34:08 +0000 Subject: [PATCH 3/5] fix: Rename AddInteractionReference to WithReference, migrate away from deprecated FFI functions Agent-Logs-Url: https://github.com/pact-foundation/pact-go/sessions/5279a831-3944-43f9-96ac-533cfda80779 Co-authored-by: mefellows <53900+mefellows@users.noreply.github.com> --- consumer/http_v4.go | 2 +- consumer/http_v4_test.go | 4 +- internal/native/message_server.go | 4 +- internal/native/mock_server.go | 110 ++++++++++----------------- internal/native/mock_server_test.go | 113 ++++++++-------------------- internal/native/verifier.go | 26 ------- internal/native/verifier_test.go | 23 ------ message/v4/asynchronous_message.go | 2 +- message/v4/synchronous_message.go | 2 +- 9 files changed, 78 insertions(+), 208 deletions(-) diff --git a/consumer/http_v4.go b/consumer/http_v4.go index b7e782e50..e57aa0f6b 100644 --- a/consumer/http_v4.go +++ b/consumer/http_v4.go @@ -96,7 +96,7 @@ func (i *V4UnconfiguredInteraction) UponReceiving(description string) *V4Unconfi // pull request) against the interaction. References appear under // comments.references[group][name] in the Pact file. May be called multiple times. func (i *V4UnconfiguredInteraction) AddExternalReference(group, name, value string) *V4UnconfiguredInteraction { - i.interaction.interaction.AddInteractionReference(group, name, value) + i.interaction.interaction.WithReference(group, name, value) return i } diff --git a/consumer/http_v4_test.go b/consumer/http_v4_test.go index 1728a1fa0..efb421bde 100644 --- a/consumer/http_v4_test.go +++ b/consumer/http_v4_test.go @@ -2,6 +2,7 @@ package consumer import ( "fmt" + "net/http" "os" "strings" "testing" @@ -98,7 +99,8 @@ func TestV4HTTPAddExternalReference(t *testing.T) { WithRequest("GET", "/", func(b *V4RequestBuilder) {}). WillRespondWith(200, func(b *V4ResponseBuilder) {}). ExecuteTest(t, func(msc MockServerConfig) error { - return nil + _, err := http.Get(fmt.Sprintf("http://%s:%d/", msc.Host, msc.Port)) + return err }) assert.NoError(t, err) } diff --git a/internal/native/message_server.go b/internal/native/message_server.go index 52af8526b..539d56d9d 100644 --- a/internal/native/message_server.go +++ b/internal/native/message_server.go @@ -597,10 +597,10 @@ func (m *MessageServer) WritePactFileForServer(port int, dir string, overwrite b } } -// AddInteractionReference records an external reference (e.g. a ticket or pull request) +// WithReference records an external reference (e.g. a ticket or pull request) // against the interaction. References are stored under comments.references[group][name] // in the Pact file. This is a V4-only feature. -func (m *Message) AddInteractionReference(group, name, value string) *Message { +func (m *Message) WithReference(group, name, value string) *Message { cGroup := C.CString(group) defer free(cGroup) cName := C.CString(name) diff --git a/internal/native/mock_server.go b/internal/native/mock_server.go index da383d0ec..a19dc3dfd 100644 --- a/internal/native/mock_server.go +++ b/internal/native/mock_server.go @@ -11,7 +11,9 @@ import ( "encoding/json" "fmt" "log" + "net" "os" + "strconv" "strings" "unsafe" ) @@ -134,52 +136,6 @@ func (m *MockServer) WithSpecificationVersion(version specificationVersion) { C.pactffi_with_specification(m.pact.handle, C.int(version)) } -// CreateMockServer creates a new Mock Server from a given Pact file. -// Returns the port number it started on or an error if failed -func (m *MockServer) CreateMockServer(pact string, address string, tls bool) (int, error) { - log.Println("[DEBUG] mock server starting on address:", address) - cPact := C.CString(pact) - cAddress := C.CString(address) - defer free(cPact) - defer free(cAddress) - tlsEnabled := false - if tls { - tlsEnabled = true - } - - p := C.pactffi_create_mock_server(cPact, cAddress, C.bool(tlsEnabled)) - - // | Error | Description | - // |-------|-------------| - // | -1 | A null pointer was received | - // | -2 | The pact JSON could not be parsed | - // | -3 | The mock server could not be started | - // | -4 | The method panicked | - // | -5 | The address is not valid | - // | -6 | Could not create the TLS configuration with the self-signed certificate | - port := int(p) - switch port { - case -1: - return 0, ErrInvalidMockServerConfig - case -2: - return 0, ErrInvalidPact - case -3: - return 0, ErrMockServerUnableToStart - case -4: - return 0, ErrMockServerPanic - case -5: - return 0, ErrInvalidAddress - case -6: - return 0, ErrMockServerTLSConfiguration - default: - if port > 0 { - log.Println("[DEBUG] mock server running on port:", port) - return port, nil - } - return port, fmt.Errorf("an unknown error (code: %v) occurred when starting a mock server for the test", port) - } -} - // Verify verifies that all interactions were successful. If not, returns a slice // of Mismatch-es. Does not write the pact or cleanup server. func (m *MockServer) Verify(port int, dir string) (bool, []MismatchedRequest) { @@ -281,50 +237,60 @@ func libRustFree(str *C.char) { } // Start starts up the mock HTTP server on the given address:port and TLS config -// https://docs.rs/pact_mock_server_ffi/0.0.7/pact_mock_server_ffi/fn.create_mock_server_for_pact.html -func (m *MockServer) Start(address string, tls bool) (int, error) { +// https://docs.rs/pact_ffi/latest/pact_ffi/mock_server/fn.pactffi_create_mock_server_for_transport.html +func (m *MockServer) Start(address string, tlsEnabled bool) (int, error) { if len(m.interactions) == 0 { return 0, ErrNoInteractions } log.Println("[DEBUG] mock server starting on address:", address) - cAddress := C.CString(address) + + host, portStr, err := net.SplitHostPort(address) + if err != nil { + return 0, ErrInvalidAddress + } + port, err := strconv.Atoi(portStr) + if err != nil { + return 0, ErrInvalidAddress + } + + cAddress := C.CString(host) defer free(cAddress) - tlsEnabled := false - if tls { - tlsEnabled = true + + transport := "http" + if tlsEnabled { + transport = "https" } + cTransport := C.CString(transport) + defer free(cTransport) - p := C.pactffi_create_mock_server_for_pact(m.pact.handle, cAddress, C.bool(tlsEnabled)) + p := C.pactffi_create_mock_server_for_transport(m.pact.handle, cAddress, C.ushort(port), cTransport, nil) - // | Error | Description | - // |-------|-------------| - // | -1 | A null pointer was received | - // | -2 | The pact JSON could not be parsed | - // | -3 | The mock server could not be started | - // | -4 | The method panicked | - // | -5 | The address is not valid | - // | -6 | Could not create the TLS configuration with the self-signed certificate | - port := int(p) - switch port { + // | Error | Description + // |-------|------------- + // | -1 | An invalid handle was received. Handles should be created with pactffi_new_pact + // | -2 | transport_config is not valid JSON + // | -3 | The mock server could not be started + // | -4 | The method panicked + // | -5 | The address is not valid + msPort := int(p) + switch msPort { case -1: return 0, ErrInvalidMockServerConfig case -2: - return 0, ErrInvalidPact + return 0, ErrInvalidMockServerConfig case -3: return 0, ErrMockServerUnableToStart case -4: return 0, ErrMockServerPanic case -5: return 0, ErrInvalidAddress - case -6: - return 0, ErrMockServerTLSConfiguration default: - if port > 0 { - log.Println("[DEBUG] mock server running on port:", port) - return port, nil + if msPort > 0 { + log.Println("[DEBUG] mock server running on port:", msPort) + return msPort, nil } - return port, fmt.Errorf("an unknown error (code: %v) occurred when starting a mock server for the test", port) + return msPort, fmt.Errorf("an unknown error (code: %v) occurred when starting a mock server for the test", msPort) } } @@ -643,10 +609,10 @@ func (i *Interaction) WithStatus(status int) *Interaction { return i } -// AddInteractionReference records an external reference (e.g. a ticket or pull request) +// WithReference records an external reference (e.g. a ticket or pull request) // against the interaction. References are stored under comments.references[group][name] // in the Pact file. This is a V4-only feature. -func (i *Interaction) AddInteractionReference(group, name, value string) *Interaction { +func (i *Interaction) WithReference(group, name, value string) *Interaction { cGroup := C.CString(group) defer free(cGroup) cName := C.CString(name) diff --git a/internal/native/mock_server_test.go b/internal/native/mock_server_test.go index aa79a6373..25f8cc135 100644 --- a/internal/native/mock_server_test.go +++ b/internal/native/mock_server_test.go @@ -17,9 +17,34 @@ func init() { Init("") } +// newSimpleMockServer creates a mock server with a simple GET /foobar → 200 interaction +// using the programmatic API, replacing the removed CreateMockServer function. +func newSimpleMockServer(t *testing.T) (*MockServer, int) { + t.Helper() + m := NewHTTPPact("consumer", "provider") + m.NewInteraction("Some name for the test"). + UponReceiving("Some name for the test"). + Given("Some state"). + WithRequest("GET", "/foobar"). + WithStatus(200) + port, err := m.Start("0.0.0.0:0", false) + if err != nil { + t.Fatalf("failed to start mock server: %v", err) + } + return m, port +} + func TestMockServer_CreateAndCleanupMockServer(t *testing.T) { - m := MockServer{} - port, _ := m.CreateMockServer(pactComplex, "0.0.0.0:0", false) + m := NewHTTPPact("consumer", "provider") + m.NewInteraction("Some complex interaction"). + UponReceiving("Some complex interaction"). + Given("Some state"). + WithRequest("GET", "/foobar"). + WithStatus(200) + port, err := m.Start("0.0.0.0:0", false) + if err != nil { + t.Fatal("failed to start mock server:", err) + } defer m.CleanupMockServer(port) if port <= 0 { @@ -28,8 +53,7 @@ func TestMockServer_CreateAndCleanupMockServer(t *testing.T) { } func TestMockServer_MismatchesSuccess(t *testing.T) { - m := MockServer{} - port, _ := m.CreateMockServer(pactSimple, "0.0.0.0:0", false) + m, port := newSimpleMockServer(t) defer m.CleanupMockServer(port) res, err := http.Get(fmt.Sprintf("http://localhost:%d/foobar", port)) @@ -48,8 +72,7 @@ func TestMockServer_MismatchesSuccess(t *testing.T) { } func TestMockServer_MismatchesFail(t *testing.T) { - m := MockServer{} - port, _ := m.CreateMockServer(pactSimple, "0.0.0.0:0", false) + m, port := newSimpleMockServer(t) defer m.CleanupMockServer(port) mismatches := m.MockServerMismatchedRequests(port) @@ -62,8 +85,7 @@ func TestMockServer_VerifySuccess(t *testing.T) { tmpPactFolder, err := os.MkdirTemp("", "pact-go") assert.NoError(t, err) - m := MockServer{} - port, _ := m.CreateMockServer(pactSimple, "0.0.0.0:0", false) + m, port := newSimpleMockServer(t) defer m.CleanupMockServer(port) _, err = http.Get(fmt.Sprintf("http://localhost:%d/foobar", port)) @@ -84,8 +106,7 @@ func TestMockServer_VerifySuccess(t *testing.T) { func TestMockServer_VerifyFail(t *testing.T) { tmpPactFolder, err := os.MkdirTemp("", "pact-go") assert.NoError(t, err) - m := MockServer{} - port, _ := m.CreateMockServer(pactSimple, "0.0.0.0:0", false) + m, port := newSimpleMockServer(t) success, mismatches := m.Verify(port, tmpPactFolder) if success { @@ -101,8 +122,7 @@ func TestMockServer_WritePactfile(t *testing.T) { tmpPactFolder, err := os.MkdirTemp("", "pact-go") assert.NoError(t, err) - m := MockServer{} - port, _ := m.CreateMockServer(pactSimple, "0.0.0.0:0", false) + m, port := newSimpleMockServer(t) defer m.CleanupMockServer(port) _, err = http.Get(fmt.Sprintf("http://localhost:%d/foobar", port)) @@ -224,72 +244,3 @@ func TestPluginInteraction(t *testing.T) { assert.NoError(t, err) } -var pactSimple = `{ - "consumer": { - "name": "consumer" - }, - "provider": { - "name": "provider" - }, - "interactions": [ - { - "description": "Some name for the test", - "request": { - "method": "GET", - "path": "/foobar" - }, - "response": { - "status": 200 - }, - "description": "Some name for the test", - "provider_state": "Some state" - }] -}` - -var pactComplex = `{ - "consumer": { - "name": "consumer" - }, - "provider": { - "name": "provider" - }, - "interactions": [ - { - "request": { - "method": "GET", - "path": "/foobar", - "body": { - "pass": 1234, - "user": { - "address": "some address", - "name": "someusername", - "phone": 12345678, - "plaintext": "plaintext" - } - } - }, - "response": { - "status": 200 - }, - "description": "Some name for the test", - "provider_state": "Some state", - "matchingRules": { - "$.body.pass": { - "match": "regex", - "regex": "\\d+" - }, - "$.body.user.address": { - "match": "regex", - "regex": "\\s+" - }, - "$.body.user.name": { - "match": "regex", - "regex": "\\s+" - }, - "$.body.user.phone": { - "match": "regex", - "regex": "\\d+" - } - } - }] -}` diff --git a/internal/native/verifier.go b/internal/native/verifier.go index 3f03df269..0e45f6b4d 100644 --- a/internal/native/verifier.go +++ b/internal/native/verifier.go @@ -8,7 +8,6 @@ import "C" import ( "fmt" "log" - "strings" "unsafe" ) @@ -16,31 +15,6 @@ type Verifier struct { handle *C.VerifierHandle } -func (v *Verifier) Verify(args []string) error { - log.Println("[DEBUG] executing verifier FFI with args", args) - cargs := C.CString(strings.Join(args, "\n")) - defer free(cargs) - result := C.pactffi_verify(cargs) - - /// | Error | Description | - /// |-------|-------------| - /// | 1 | The verification process failed, see output for errors | - /// | 2 | A null pointer was received | - /// | 3 | The method panicked | - switch int(result) { - case 0: - return nil - case 1: - return ErrVerifierFailed - case 2: - return ErrInvalidVerifierConfig - case 3: - return ErrVerifierPanic - default: - return fmt.Errorf("an unknown error (%d) ocurred when verifying the provider (this indicates a defect in the framework)", int(result)) - } -} - // Version returns the current semver FFI interface version func (v *Verifier) Version() string { return Version() diff --git a/internal/native/verifier_test.go b/internal/native/verifier_test.go index 714d65551..5d6b3cb33 100644 --- a/internal/native/verifier_test.go +++ b/internal/native/verifier_test.go @@ -18,29 +18,6 @@ func TestVerifier_Version(t *testing.T) { fmt.Println("version: ", Version()) } -func TestVerifier_Verify(t *testing.T) { - t.Run("invalid args returns an error", func(t *testing.T) { - - v := Verifier{} - args := []string{ - "--file", - "/non/existent/path.json", - "--hostname", - "localhost", - "--port", - "55827", - "--state-change-url", - "http://localhost:55827/__setup/", - "--loglevel", - "info", - } - - res := v.Verify(args) - - assert.Error(t, res) - }) -} - func TestVerifier_NewForApplication(t *testing.T) { v := NewVerifier("pact-go", "test") diff --git a/message/v4/asynchronous_message.go b/message/v4/asynchronous_message.go index 21d1d236e..c57cc8c37 100644 --- a/message/v4/asynchronous_message.go +++ b/message/v4/asynchronous_message.go @@ -51,7 +51,7 @@ func (m *AsynchronousMessageBuilder) GivenWithParameter(state models.ProviderSta // pull request) against the interaction. References appear under // comments.references[group][name] in the Pact file. May be called multiple times. func (m *AsynchronousMessageBuilder) AddExternalReference(group, name, value string) *AsynchronousMessageBuilder { - m.messageHandle.AddInteractionReference(group, name, value) + m.messageHandle.WithReference(group, name, value) return m } diff --git a/message/v4/synchronous_message.go b/message/v4/synchronous_message.go index 03c1eaca0..6088703ba 100644 --- a/message/v4/synchronous_message.go +++ b/message/v4/synchronous_message.go @@ -67,7 +67,7 @@ type UnconfiguredSynchronousMessageBuilder struct { // pull request) against the interaction. References appear under // comments.references[group][name] in the Pact file. May be called multiple times. func (m *UnconfiguredSynchronousMessageBuilder) AddExternalReference(group, name, value string) *UnconfiguredSynchronousMessageBuilder { - m.messageHandle.AddInteractionReference(group, name, value) + m.messageHandle.WithReference(group, name, value) return m } From b9fdecc64ef9092a99bdfb2710119dbc03593623 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 08:37:17 +0000 Subject: [PATCH 4/5] fix: Pass empty JSON object as transport_config in Start() for consistency Agent-Logs-Url: https://github.com/pact-foundation/pact-go/sessions/5279a831-3944-43f9-96ac-533cfda80779 Co-authored-by: mefellows <53900+mefellows@users.noreply.github.com> --- internal/native/mock_server.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/native/mock_server.go b/internal/native/mock_server.go index a19dc3dfd..354014858 100644 --- a/internal/native/mock_server.go +++ b/internal/native/mock_server.go @@ -264,7 +264,10 @@ func (m *MockServer) Start(address string, tlsEnabled bool) (int, error) { cTransport := C.CString(transport) defer free(cTransport) - p := C.pactffi_create_mock_server_for_transport(m.pact.handle, cAddress, C.ushort(port), cTransport, nil) + cConfig := C.CString("{}") + defer free(cConfig) + + p := C.pactffi_create_mock_server_for_transport(m.pact.handle, cAddress, C.ushort(port), cTransport, cConfig) // | Error | Description // |-------|------------- From 54a4da988d913fab530370d8225942d9ea3579a6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 05:03:03 +0000 Subject: [PATCH 5/5] fix: Implement Go-level TLS proxy for TLS mock servers (pactffi_create_mock_server_for_transport doesn't support https transport) Agent-Logs-Url: https://github.com/pact-foundation/pact-go/sessions/ed62bbfe-8577-4130-ad4a-cc24f4588070 Co-authored-by: mefellows <53900+mefellows@users.noreply.github.com> --- internal/native/mock_server.go | 154 ++++++++++++++++++++++++++++++--- 1 file changed, 142 insertions(+), 12 deletions(-) diff --git a/internal/native/mock_server.go b/internal/native/mock_server.go index 354014858..6d9e4a43a 100644 --- a/internal/native/mock_server.go +++ b/internal/native/mock_server.go @@ -6,15 +6,25 @@ package native import "C" import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "crypto/tls" "crypto/x509" + "crypto/x509/pkix" "encoding/json" + "encoding/pem" "fmt" "log" + "math/big" "net" + "net/http" + "net/http/httputil" + "net/url" "os" "strconv" "strings" + "time" "unsafe" ) @@ -115,6 +125,18 @@ type MockServer struct { pact *Pact messagePact *MessagePact interactions []*Interaction + tlsProxy net.Listener // non-nil when a Go-level TLS proxy is in use + tlsServer *http.Server // HTTP server for the TLS proxy (enables graceful shutdown) + internalPort int // actual pact FFI server port when TLS proxy is active +} + +// pactPort returns the port the FFI mock server is bound to. +// When a TLS proxy is active, this differs from the external port callers see. +func (m *MockServer) pactPort(visiblePort int) int { + if m.internalPort != 0 { + return m.internalPort + } + return visiblePort } // NewHTTPPact creates a new HTTP mock server for a given consumer/provider @@ -151,7 +173,7 @@ func (m *MockServer) MockServerMismatchedRequests(port int) []MismatchedRequest log.Println("[DEBUG] mock server determining mismatches:", port) var res []MismatchedRequest - mismatches := C.pactffi_mock_server_mismatches(C.int(port)) + mismatches := C.pactffi_mock_server_mismatches(C.int(m.pactPort(port))) // This method can return a nil pointer, in which case, it // should be considered a failure (or at least, an issue) // converting it to a string might also do nasty things here! @@ -174,7 +196,19 @@ func (m *MockServer) CleanupMockServer(port int) bool { return true } log.Println("[DEBUG] mock server cleaning up port:", port) - res := C.pactffi_cleanup_mock_server(C.int(port)) + + // Shut down the TLS proxy cleanly if active. + if m.tlsServer != nil { + _ = m.tlsServer.Close() + m.tlsServer = nil + } + if m.tlsProxy != nil { + _ = m.tlsProxy.Close() + m.tlsProxy = nil + m.internalPort = 0 + } + + res := C.pactffi_cleanup_mock_server(C.int(m.pactPort(port))) return bool(res) } @@ -192,7 +226,7 @@ func (m *MockServer) WritePactFile(port int, dir string) error { // } // res := int(C.pactffi_write_pact_file(C.int(port), cDir, C.int(overwritePact))) - res := int(C.pactffi_write_pact_file(C.int(port), cDir, C.bool(false))) + res := int(C.pactffi_write_pact_file(C.int(m.pactPort(port)), cDir, C.bool(false))) // | Error | Description | // |-------|-------------| @@ -236,7 +270,11 @@ func libRustFree(str *C.char) { C.pactffi_free_string(str) } -// Start starts up the mock HTTP server on the given address:port and TLS config +// Start starts up the mock HTTP server on the given address:port and TLS config. +// When tlsEnabled is true, a plain HTTP pact mock server is started on an +// auto-assigned port and a Go-level TLS termination proxy is placed in front +// of it on the requested port. All FFI verification/cleanup calls are directed +// to the internal plain HTTP port via pactPort(). // https://docs.rs/pact_ffi/latest/pact_ffi/mock_server/fn.pactffi_create_mock_server_for_transport.html func (m *MockServer) Start(address string, tlsEnabled bool) (int, error) { if len(m.interactions) == 0 { @@ -249,21 +287,48 @@ func (m *MockServer) Start(address string, tlsEnabled bool) (int, error) { if err != nil { return 0, ErrInvalidAddress } - port, err := strconv.Atoi(portStr) + requestedPort, err := strconv.Atoi(portStr) if err != nil { return 0, ErrInvalidAddress } - cAddress := C.CString(host) - defer free(cAddress) - - transport := "http" + // When TLS is requested, let the OS pick the internal port for the plain HTTP + // pact mock server so it doesn't collide with the TLS proxy port. + ffiPort := requestedPort if tlsEnabled { - transport = "https" + ffiPort = 0 } - cTransport := C.CString(transport) - defer free(cTransport) + internalPort, err := m.startFFIMockServer(host, ffiPort) + if err != nil { + return 0, err + } + + if !tlsEnabled { + return internalPort, nil + } + + // Stand up a Go TLS reverse proxy in front of the plain HTTP pact server. + tlsListener, err := m.startTLSProxy(host, requestedPort, internalPort) + if err != nil { + C.pactffi_cleanup_mock_server(C.int(internalPort)) + return 0, fmt.Errorf("failed to start TLS proxy: %w", err) + } + + m.internalPort = internalPort + m.tlsProxy = tlsListener + + proxyPort := tlsListener.Addr().(*net.TCPAddr).Port + log.Println("[DEBUG] TLS proxy running on port:", proxyPort, "-> internal port:", internalPort) + return proxyPort, nil +} + +// startFFIMockServer starts the pact FFI mock server as plain HTTP. +func (m *MockServer) startFFIMockServer(host string, port int) (int, error) { + cAddress := C.CString(host) + defer free(cAddress) + cTransport := C.CString("http") + defer free(cTransport) cConfig := C.CString("{}") defer free(cConfig) @@ -297,6 +362,71 @@ func (m *MockServer) Start(address string, tlsEnabled bool) (int, error) { } } +// startTLSProxy creates a self-signed certificate, starts a TLS listener on +// proxyPort (0 = OS-assigned) and launches a reverse-proxy goroutine that +// forwards plain HTTP to the pact mock server on backendPort. +func (m *MockServer) startTLSProxy(host string, proxyPort int, backendPort int) (net.Listener, error) { + cert, err := generateSelfSignedCert() + if err != nil { + return nil, err + } + + tlsConfig := &tls.Config{Certificates: []tls.Certificate{cert}} + listener, err := tls.Listen("tcp", fmt.Sprintf("%s:%d", host, proxyPort), tlsConfig) + if err != nil { + return nil, err + } + + backendURL := &url.URL{ + Scheme: "http", + Host: fmt.Sprintf("127.0.0.1:%d", backendPort), + } + proxy := httputil.NewSingleHostReverseProxy(backendURL) + srv := &http.Server{Handler: proxy} + m.tlsServer = srv + go func() { + if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed { + log.Println("[ERROR] TLS proxy serve error:", err) + } + }() + + return listener, nil +} + +// generateSelfSignedCert creates an ECDSA P-256 self-signed certificate +// valid for localhost / 127.0.0.1 for 24 hours. +func generateSelfSignedCert() (tls.Certificate, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return tls.Certificate{}, err + } + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "pact-go"}, + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, + DNSNames: []string{"localhost"}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + return tls.Certificate{}, err + } + + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + return tls.Certificate{}, err + } + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + return tls.X509KeyPair(certPEM, keyPEM) +} + // StartTransport starts up a mock server on the given address:port for the given transport // https://docs.rs/pact_ffi/latest/pact_ffi/mock_server/fn.pactffi_create_mock_server_for_transport.html func (m *MockServer) StartTransport(transport string, address string, port int, config map[string][]interface{}) (int, error) {