github.com/mutagen-io/mutagen@v0.18.0-rc1/pkg/integration/internal_api_test.go (about)

     1  package integration
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"io"
     8  	"net/http"
     9  	"os"
    10  	"path/filepath"
    11  	"runtime"
    12  	"testing"
    13  
    14  	"github.com/google/uuid"
    15  
    16  	"github.com/mutagen-io/mutagen/pkg/forwarding"
    17  	"github.com/mutagen-io/mutagen/pkg/forwarding/endpoint/local"
    18  	"github.com/mutagen-io/mutagen/pkg/integration/fixtures/constants"
    19  	"github.com/mutagen-io/mutagen/pkg/integration/protocols/netpipe"
    20  	"github.com/mutagen-io/mutagen/pkg/prompting"
    21  	"github.com/mutagen-io/mutagen/pkg/selection"
    22  	"github.com/mutagen-io/mutagen/pkg/synchronization"
    23  	"github.com/mutagen-io/mutagen/pkg/synchronization/compression"
    24  	"github.com/mutagen-io/mutagen/pkg/synchronization/hashing"
    25  	"github.com/mutagen-io/mutagen/pkg/url"
    26  )
    27  
    28  func waitForSuccessfulSynchronizationCycle(ctx context.Context, sessionID string, allowScanProblems, allowConflicts, allowTransitionProblems bool) error {
    29  	// Create a session selection specification.
    30  	selection := &selection.Selection{
    31  		Specifications: []string{sessionID},
    32  	}
    33  
    34  	// Perform waiting.
    35  	var previousStateIndex uint64
    36  	var states []*synchronization.State
    37  	var err error
    38  	for {
    39  		previousStateIndex, states, err = synchronizationManager.List(ctx, selection, previousStateIndex)
    40  		if err != nil {
    41  			return fmt.Errorf("unable to list session states: %w", err)
    42  		} else if len(states) != 1 {
    43  			return errors.New("invalid number of session states returned")
    44  		} else if states[0].SuccessfulCycles > 0 {
    45  			if !allowScanProblems && (len(states[0].AlphaState.ScanProblems) > 0 || len(states[0].BetaState.ScanProblems) > 0) {
    46  				return errors.New("scan problems detected (and disallowed)")
    47  			} else if !allowConflicts && len(states[0].Conflicts) > 0 {
    48  				return errors.New("conflicts detected (and disallowed)")
    49  			} else if !allowTransitionProblems && (len(states[0].AlphaState.TransitionProblems) > 0 || len(states[0].BetaState.TransitionProblems) > 0) {
    50  				return errors.New("transition problems detected (and disallowed)")
    51  			}
    52  			return nil
    53  		}
    54  	}
    55  }
    56  
    57  func testSessionLifecycle(ctx context.Context, prompter string, alpha, beta *url.URL, configuration *synchronization.Configuration, allowScanProblems, allowConflicts, allowTransitionProblems bool) error {
    58  	// Create a session.
    59  	sessionID, err := synchronizationManager.Create(
    60  		ctx,
    61  		alpha, beta,
    62  		configuration,
    63  		&synchronization.Configuration{},
    64  		&synchronization.Configuration{},
    65  		"testSynchronizationSession",
    66  		nil,
    67  		false,
    68  		prompter,
    69  	)
    70  	if err != nil {
    71  		return fmt.Errorf("unable to create session: %w", err)
    72  	}
    73  
    74  	// Wait for the session to have at least one successful synchronization
    75  	// cycle.
    76  	// TODO: Should we add a timeout on this?
    77  	if err := waitForSuccessfulSynchronizationCycle(ctx, sessionID, allowScanProblems, allowConflicts, allowTransitionProblems); err != nil {
    78  		return fmt.Errorf("unable to wait for successful synchronization: %w", err)
    79  	}
    80  
    81  	// TODO: Add hook for verifying file contents.
    82  
    83  	// TODO: Add hook for verifying presence/absence of particular
    84  	// conflicts/problems and remove that monitoring from
    85  	// waitForSuccessfulSynchronizationCycle (maybe have it pass back the
    86  	// relevant state).
    87  
    88  	// Create a session selection specification.
    89  	selection := &selection.Selection{
    90  		Specifications: []string{sessionID},
    91  	}
    92  
    93  	// Pause the session.
    94  	if err := synchronizationManager.Pause(ctx, selection, ""); err != nil {
    95  		return fmt.Errorf("unable to pause session: %w", err)
    96  	}
    97  
    98  	// Resume the session.
    99  	if err := synchronizationManager.Resume(ctx, selection, ""); err != nil {
   100  		return fmt.Errorf("unable to resume session: %w", err)
   101  	}
   102  
   103  	// Wait for the session to have at least one additional synchronization
   104  	// cycle.
   105  	if err := waitForSuccessfulSynchronizationCycle(ctx, sessionID, allowScanProblems, allowConflicts, allowTransitionProblems); err != nil {
   106  		return fmt.Errorf("unable to wait for additional synchronization: %w", err)
   107  	}
   108  
   109  	// Attempt an additional resume (this should be a no-op).
   110  	if err := synchronizationManager.Resume(ctx, selection, ""); err != nil {
   111  		return fmt.Errorf("unable to perform additional resume: %w", err)
   112  	}
   113  
   114  	// Terminate the session.
   115  	if err := synchronizationManager.Terminate(ctx, selection, ""); err != nil {
   116  		return fmt.Errorf("unable to terminate session: %w", err)
   117  	}
   118  
   119  	// TODO: Verify that cleanup took place.
   120  
   121  	// Success.
   122  	return nil
   123  }
   124  
   125  func TestSynchronizationBothRootsNil(t *testing.T) {
   126  	// Allow this test to run in parallel.
   127  	t.Parallel()
   128  
   129  	// Calculate alpha and beta paths.
   130  	directory := t.TempDir()
   131  	alphaRoot := filepath.Join(directory, "alpha")
   132  	betaRoot := filepath.Join(directory, "beta")
   133  
   134  	// Compute alpha and beta URLs.
   135  	alphaURL := &url.URL{Path: alphaRoot}
   136  	betaURL := &url.URL{Path: betaRoot}
   137  
   138  	// Compute configuration. We use defaults for everything.
   139  	configuration := &synchronization.Configuration{}
   140  
   141  	// Test the session lifecycle.
   142  	if err := testSessionLifecycle(context.Background(), "", alphaURL, betaURL, configuration, false, false, false); err != nil {
   143  		t.Error("session lifecycle test failed:", err)
   144  	}
   145  }
   146  
   147  func TestSynchronizationGOROOTSrcToBeta(t *testing.T) {
   148  	// Check the end-to-end test mode and compute the source synchronization
   149  	// root accordingly. If no mode has been specified, then skip the test.
   150  	endToEndTestMode := os.Getenv("MUTAGEN_TEST_END_TO_END")
   151  	var sourceRoot string
   152  	if endToEndTestMode == "" {
   153  		t.Skip()
   154  	} else if endToEndTestMode == "full" {
   155  		sourceRoot = filepath.Join(runtime.GOROOT(), "src")
   156  	} else if endToEndTestMode == "slim" {
   157  		sourceRoot = filepath.Join(runtime.GOROOT(), "src", "bufio")
   158  	} else {
   159  		t.Fatal("unknown end-to-end test mode specified:", endToEndTestMode)
   160  	}
   161  
   162  	// Allow the test to run in parallel.
   163  	t.Parallel()
   164  
   165  	// Calculate alpha and beta paths.
   166  	alphaRoot := sourceRoot
   167  	betaRoot := filepath.Join(t.TempDir(), "beta")
   168  
   169  	// Compute alpha and beta URLs.
   170  	alphaURL := &url.URL{Path: alphaRoot}
   171  	betaURL := &url.URL{Path: betaRoot}
   172  
   173  	// Compute configuration. We use defaults for everything.
   174  	configuration := &synchronization.Configuration{}
   175  
   176  	// Test the session lifecycle.
   177  	if err := testSessionLifecycle(context.Background(), "", alphaURL, betaURL, configuration, false, false, false); err != nil {
   178  		t.Error("session lifecycle test failed:", err)
   179  	}
   180  }
   181  
   182  func TestSynchronizationGOROOTSrcToAlpha(t *testing.T) {
   183  	// Check the end-to-end test mode and compute the source synchronization
   184  	// root accordingly. If no mode has been specified, then skip the test.
   185  	endToEndTestMode := os.Getenv("MUTAGEN_TEST_END_TO_END")
   186  	var sourceRoot string
   187  	if endToEndTestMode == "" {
   188  		t.Skip()
   189  	} else if endToEndTestMode == "full" {
   190  		sourceRoot = filepath.Join(runtime.GOROOT(), "src")
   191  	} else if endToEndTestMode == "slim" {
   192  		sourceRoot = filepath.Join(runtime.GOROOT(), "src", "bufio")
   193  	} else {
   194  		t.Fatal("unknown end-to-end test mode specified:", endToEndTestMode)
   195  	}
   196  
   197  	// Allow the test to run in parallel.
   198  	t.Parallel()
   199  
   200  	// Calculate alpha and beta paths.
   201  	alphaRoot := filepath.Join(t.TempDir(), "alpha")
   202  	betaRoot := sourceRoot
   203  
   204  	// Compute alpha and beta URLs.
   205  	alphaURL := &url.URL{Path: alphaRoot}
   206  	betaURL := &url.URL{Path: betaRoot}
   207  
   208  	// Compute configuration. We use defaults for everything.
   209  	configuration := &synchronization.Configuration{}
   210  
   211  	// Test the session lifecycle.
   212  	if err := testSessionLifecycle(context.Background(), "", alphaURL, betaURL, configuration, false, false, false); err != nil {
   213  		t.Error("session lifecycle test failed:", err)
   214  	}
   215  }
   216  
   217  func TestSynchronizationGOROOTSrcToBetaInMemory(t *testing.T) {
   218  	// Define configuration variations.
   219  	testCases := []*synchronization.Configuration{
   220  		{},
   221  		{
   222  			CompressionAlgorithm: compression.Algorithm_AlgorithmNone,
   223  		},
   224  		{
   225  			HashingAlgorithm: hashing.Algorithm_AlgorithmSHA256,
   226  		},
   227  	}
   228  	if hashing.Algorithm_AlgorithmXXH128.SupportStatus() == hashing.AlgorithmSupportStatusSupported {
   229  		testCases = append(testCases, &synchronization.Configuration{
   230  			HashingAlgorithm: hashing.Algorithm_AlgorithmXXH128,
   231  		})
   232  	}
   233  	if compression.Algorithm_AlgorithmZstandard.SupportStatus() == compression.AlgorithmSupportStatusSupported {
   234  		testCases = append(testCases, &synchronization.Configuration{
   235  			CompressionAlgorithm: compression.Algorithm_AlgorithmZstandard,
   236  		})
   237  	}
   238  
   239  	// Check the end-to-end test mode and compute the source synchronization
   240  	// root accordingly. If no mode has been specified, then skip the test.
   241  	endToEndTestMode := os.Getenv("MUTAGEN_TEST_END_TO_END")
   242  	var sourceRoot string
   243  	if endToEndTestMode == "" {
   244  		t.Skip()
   245  	} else if endToEndTestMode == "full" {
   246  		sourceRoot = filepath.Join(runtime.GOROOT(), "src")
   247  	} else if endToEndTestMode == "slim" {
   248  		sourceRoot = filepath.Join(runtime.GOROOT(), "src", "bufio")
   249  	} else {
   250  		t.Fatal("unknown end-to-end test mode specified:", endToEndTestMode)
   251  	}
   252  
   253  	// Allow the test to run in parallel.
   254  	t.Parallel()
   255  
   256  	// Loop over configurations and test the session lifecycle.
   257  	for _, configuration := range testCases {
   258  		// Calculate alpha and beta paths.
   259  		alphaRoot := sourceRoot
   260  		betaRoot := filepath.Join(t.TempDir(), "beta")
   261  
   262  		// Compute alpha and beta URLs. We use a special protocol with a custom
   263  		// handler to indicate an in-memory connection.
   264  		alphaURL := &url.URL{Path: alphaRoot}
   265  		betaURL := &url.URL{
   266  			Protocol: netpipe.Protocol_Netpipe,
   267  			Path:     betaRoot,
   268  		}
   269  
   270  		// Test the session lifecycle.
   271  		if err := testSessionLifecycle(context.Background(), "", alphaURL, betaURL, configuration, false, false, false); err != nil {
   272  			t.Error("session lifecycle test failed:", err)
   273  		}
   274  	}
   275  }
   276  
   277  func TestSynchronizationGOROOTSrcToBetaOverSSH(t *testing.T) {
   278  	// If localhost SSH support isn't available, then skip this test.
   279  	if os.Getenv("MUTAGEN_TEST_SSH") != "true" {
   280  		t.Skip()
   281  	}
   282  
   283  	// Check the end-to-end test mode and compute the source synchronization
   284  	// root accordingly. If no mode has been specified, then skip the test.
   285  	endToEndTestMode := os.Getenv("MUTAGEN_TEST_END_TO_END")
   286  	var sourceRoot string
   287  	if endToEndTestMode == "" {
   288  		t.Skip()
   289  	} else if endToEndTestMode == "full" {
   290  		sourceRoot = filepath.Join(runtime.GOROOT(), "src")
   291  	} else if endToEndTestMode == "slim" {
   292  		sourceRoot = filepath.Join(runtime.GOROOT(), "src", "bufio")
   293  	} else {
   294  		t.Fatal("unknown end-to-end test mode specified:", endToEndTestMode)
   295  	}
   296  
   297  	// Allow the test to run in parallel.
   298  	t.Parallel()
   299  
   300  	// Calculate alpha and beta paths.
   301  	alphaRoot := sourceRoot
   302  	betaRoot := filepath.Join(t.TempDir(), "beta")
   303  
   304  	// Compute alpha and beta URLs.
   305  	alphaURL := &url.URL{Path: alphaRoot}
   306  	betaURL := &url.URL{
   307  		Protocol: url.Protocol_SSH,
   308  		Host:     "localhost",
   309  		Path:     betaRoot,
   310  	}
   311  
   312  	// Compute configuration. We use defaults for everything.
   313  	configuration := &synchronization.Configuration{}
   314  
   315  	// Test the session lifecycle.
   316  	if err := testSessionLifecycle(context.Background(), "", alphaURL, betaURL, configuration, false, false, false); err != nil {
   317  		t.Error("session lifecycle test failed:", err)
   318  	}
   319  }
   320  
   321  // testWindowsDockerTransportPrompter is a prompting.Prompter implementation
   322  // that will answer "yes" to all prompts. It's needed to confirm container
   323  // restart behavior in the Docker transport on Windows.
   324  type testWindowsDockerTransportPrompter struct{}
   325  
   326  func (t *testWindowsDockerTransportPrompter) Message(_ string) error {
   327  	return nil
   328  }
   329  
   330  func (t *testWindowsDockerTransportPrompter) Prompt(_ string) (string, error) {
   331  	return "yes", nil
   332  }
   333  
   334  func TestSynchronizationGOROOTSrcToBetaOverDocker(t *testing.T) {
   335  	// If Docker test support isn't available, then skip this test.
   336  	if os.Getenv("MUTAGEN_TEST_DOCKER") != "true" {
   337  		t.Skip()
   338  	}
   339  
   340  	// Check the end-to-end test mode and compute the source synchronization
   341  	// root accordingly. If no mode has been specified, then skip the test.
   342  	endToEndTestMode := os.Getenv("MUTAGEN_TEST_END_TO_END")
   343  	var sourceRoot string
   344  	if endToEndTestMode == "" {
   345  		t.Skip()
   346  	} else if endToEndTestMode == "full" {
   347  		sourceRoot = filepath.Join(runtime.GOROOT(), "src")
   348  	} else if endToEndTestMode == "slim" {
   349  		sourceRoot = filepath.Join(runtime.GOROOT(), "src", "bufio")
   350  	} else {
   351  		t.Fatal("unknown end-to-end test mode specified:", endToEndTestMode)
   352  	}
   353  
   354  	// If we're on a POSIX system, then allow this test to run concurrently with
   355  	// other tests. On Windows, agent installation into Docker containers
   356  	// requires temporarily halting the container, meaning that multiple
   357  	// simultaneous Docker tests could conflict with each other, so we don't
   358  	// allow Docker-based tests to run concurrently on Windows.
   359  	if runtime.GOOS != "windows" {
   360  		t.Parallel()
   361  	}
   362  
   363  	// If we're on Windows, register a prompter that will answer yes to
   364  	// questions about stoping and restarting containers.
   365  	var prompter string
   366  	if runtime.GOOS == "windows" {
   367  		if p, err := prompting.RegisterPrompter(&testWindowsDockerTransportPrompter{}); err != nil {
   368  			t.Fatal("unable to register prompter:", err)
   369  		} else {
   370  			prompter = p
   371  			defer prompting.UnregisterPrompter(prompter)
   372  		}
   373  	}
   374  
   375  	// Create a unique directory name for synchronization into the container. We
   376  	// don't clean it up, because it will be wiped out when the test container
   377  	// is deleted.
   378  	randomUUID, err := uuid.NewRandom()
   379  	if err != nil {
   380  		t.Fatal("unable to create random directory UUID:", err)
   381  	}
   382  
   383  	// Calculate alpha and beta paths.
   384  	alphaRoot := sourceRoot
   385  	betaRoot := "~/" + randomUUID.String()
   386  
   387  	// Grab Docker environment variables.
   388  	environment := make(map[string]string, len(url.DockerEnvironmentVariables))
   389  	for _, variable := range url.DockerEnvironmentVariables {
   390  		environment[variable] = os.Getenv(variable)
   391  	}
   392  
   393  	// Compute alpha and beta URLs.
   394  	alphaURL := &url.URL{Path: alphaRoot}
   395  	betaURL := &url.URL{
   396  		Protocol:    url.Protocol_Docker,
   397  		User:        os.Getenv("MUTAGEN_TEST_DOCKER_USERNAME"),
   398  		Host:        os.Getenv("MUTAGEN_TEST_DOCKER_CONTAINER_NAME"),
   399  		Path:        betaRoot,
   400  		Environment: environment,
   401  	}
   402  
   403  	// Verify that the beta URL is valid (this will validate the test
   404  	// environment variables as well).
   405  	if err := betaURL.EnsureValid(); err != nil {
   406  		t.Fatal("beta URL is invalid:", err)
   407  	}
   408  
   409  	// Compute configuration. We use defaults for everything.
   410  	configuration := &synchronization.Configuration{}
   411  
   412  	// Test the session lifecycle.
   413  	if err := testSessionLifecycle(context.Background(), prompter, alphaURL, betaURL, configuration, false, false, false); err != nil {
   414  		t.Error("session lifecycle test failed:", err)
   415  	}
   416  }
   417  
   418  func init() {
   419  	// HACK: Disable lazy listener initialization since it makes test
   420  	// coordination difficult.
   421  	local.DisableLazyListenerInitialization = true
   422  }
   423  
   424  func TestForwardingToHTTPDemo(t *testing.T) {
   425  	// If Docker test support isn't available, then skip this test.
   426  	if os.Getenv("MUTAGEN_TEST_DOCKER") != "true" {
   427  		t.Skip()
   428  	}
   429  
   430  	// If we're on a POSIX system, then allow this test to run concurrently with
   431  	// other tests. On Windows, agent installation into Docker containers
   432  	// requires temporarily halting the container, meaning that multiple
   433  	// simultaneous Docker tests could conflict with each other, so we don't
   434  	// allow Docker-based tests to run concurrently on Windows.
   435  	if runtime.GOOS != "windows" {
   436  		t.Parallel()
   437  	}
   438  
   439  	// If we're on Windows, register a prompter that will answer yes to
   440  	// questions about stoping and restarting containers.
   441  	var prompter string
   442  	if runtime.GOOS == "windows" {
   443  		if p, err := prompting.RegisterPrompter(&testWindowsDockerTransportPrompter{}); err != nil {
   444  			t.Fatal("unable to register prompter:", err)
   445  		} else {
   446  			prompter = p
   447  			defer prompting.UnregisterPrompter(prompter)
   448  		}
   449  	}
   450  
   451  	// Pick a local listener address.
   452  	listenerProtocol := "tcp"
   453  	listenerAddress := "localhost:7070"
   454  
   455  	// Compute source and destination URLs.
   456  	source := &url.URL{
   457  		Kind:     url.Kind_Forwarding,
   458  		Protocol: url.Protocol_Local,
   459  		Path:     listenerProtocol + ":" + listenerAddress,
   460  	}
   461  	destination := &url.URL{
   462  		Kind:     url.Kind_Forwarding,
   463  		Protocol: url.Protocol_Docker,
   464  		User:     os.Getenv("MUTAGEN_TEST_DOCKER_USERNAME"),
   465  		Host:     os.Getenv("MUTAGEN_TEST_DOCKER_CONTAINER_NAME"),
   466  		Path:     "tcp:" + constants.HTTPDemoBindAddress,
   467  	}
   468  
   469  	// Verify that the destination URL is valid (this will validate the test
   470  	// environment variables as well).
   471  	if err := destination.EnsureValid(); err != nil {
   472  		t.Fatal("beta URL is invalid:", err)
   473  	}
   474  
   475  	// Create a function to perform a simple HTTP request and ensure that the
   476  	// returned contents are as expected.
   477  	performHTTPRequest := func() error {
   478  		// Perform the request and defer closure of the response body.
   479  		response, err := http.Get(fmt.Sprintf("http://%s/", listenerAddress))
   480  		if err != nil {
   481  			return fmt.Errorf("unable to perform HTTP GET: %w", err)
   482  		}
   483  		defer response.Body.Close()
   484  
   485  		// Read the full body.
   486  		message, err := io.ReadAll(response.Body)
   487  		if err != nil {
   488  			return fmt.Errorf("unable to read response body: %w", err)
   489  		}
   490  
   491  		// Compare the message.
   492  		if string(message) != constants.HTTPDemoResponse {
   493  			return errors.New("response does not match expected")
   494  		}
   495  
   496  		// Success.
   497  		return nil
   498  	}
   499  
   500  	// Create a context to regulate the test.
   501  	ctx := context.Background()
   502  
   503  	// Create a forwarding session. Note that we've disabled lazy listener
   504  	// initialization using a private API in the init function above, so we can
   505  	// be sure that the listener has been established (with some non-empty
   506  	// backlog) by the time creation is complete.
   507  	sessionID, err := forwardingManager.Create(
   508  		ctx,
   509  		source,
   510  		destination,
   511  		&forwarding.Configuration{},
   512  		&forwarding.Configuration{},
   513  		&forwarding.Configuration{},
   514  		"testForwardingSession",
   515  		nil,
   516  		false,
   517  		prompter,
   518  	)
   519  	if err != nil {
   520  		t.Fatal("unable to create session:", err)
   521  	}
   522  
   523  	// Attempt an HTTP request.
   524  	// TODO: Attempt a more complicated exchange here. Maybe gRPC?
   525  	if err := performHTTPRequest(); err != nil {
   526  		t.Error("error performing forwarded HTTP request:", err)
   527  	}
   528  
   529  	// Create a session selection specification.
   530  	selection := &selection.Selection{
   531  		Specifications: []string{sessionID},
   532  	}
   533  
   534  	// Pause the session.
   535  	if err := forwardingManager.Pause(ctx, selection, ""); err != nil {
   536  		t.Error("unable to pause session:", err)
   537  	}
   538  
   539  	// Resume the session.
   540  	if err := forwardingManager.Resume(ctx, selection, ""); err != nil {
   541  		t.Error("unable to resume session:", err)
   542  	}
   543  
   544  	// Attempt an HTTP request.
   545  	// TODO: Attempt a more complicated exchange here. Maybe gRPC?
   546  	if err := performHTTPRequest(); err != nil {
   547  		t.Error("error performing forwarded HTTP request:", err)
   548  	}
   549  
   550  	// Attempt an additional resume (this should be a no-op).
   551  	if err := forwardingManager.Resume(ctx, selection, ""); err != nil {
   552  		t.Error("unable to perform additional resume:", err)
   553  	}
   554  
   555  	// Terminate the session.
   556  	if err := forwardingManager.Terminate(ctx, selection, ""); err != nil {
   557  		t.Error("unable to terminate session:", err)
   558  	}
   559  
   560  	// TODO: Verify that cleanup took place.
   561  }
   562  
   563  // TODO: Add forwarding tests using the netpipe protocol.