github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/model/app/installer_konnector_test.go (about)

     1  package app_test
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"io"
     7  	"net/http"
     8  	"net/http/httptest"
     9  	"os"
    10  	"os/exec"
    11  	"path"
    12  	"testing"
    13  	"time"
    14  
    15  	"github.com/andybalholm/brotli"
    16  	"github.com/cozy/cozy-stack/model/app"
    17  	"github.com/cozy/cozy-stack/model/instance"
    18  	"github.com/cozy/cozy-stack/model/instance/lifecycle"
    19  	"github.com/cozy/cozy-stack/model/stack"
    20  	"github.com/cozy/cozy-stack/pkg/appfs"
    21  	"github.com/cozy/cozy-stack/pkg/config/config"
    22  	"github.com/cozy/cozy-stack/pkg/consts"
    23  	"github.com/cozy/cozy-stack/pkg/couchdb"
    24  	"github.com/cozy/cozy-stack/tests/testutils"
    25  	"github.com/spf13/afero"
    26  	"github.com/stretchr/testify/assert"
    27  	"github.com/stretchr/testify/require"
    28  	"golang.org/x/sync/errgroup"
    29  )
    30  
    31  func TestInstallerKonnector(t *testing.T) {
    32  	if testing.Short() {
    33  		t.Skip("an instance is required for this test: test skipped due to the use of --short flag")
    34  	}
    35  
    36  	config.UseTestFile(t)
    37  
    38  	testutils.NeedCouchdb(t)
    39  
    40  	gitURL, done := serveGitRep(t)
    41  	defer done()
    42  
    43  	for i := 0; i < 400; i++ {
    44  		if err := exec.Command("git", "ls-remote", gitURL).Run(); err == nil {
    45  			break
    46  		}
    47  		time.Sleep(10 * time.Millisecond)
    48  	}
    49  
    50  	if !stackStarted {
    51  		_, _, err := stack.Start()
    52  		if err != nil {
    53  			require.NoError(t, err, "Error while starting job system")
    54  		}
    55  		stackStarted = true
    56  	}
    57  
    58  	app.ManifestClient = &http.Client{Transport: &transport{}}
    59  
    60  	ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    61  		_, _ = io.WriteString(w, manGen())
    62  	}))
    63  	t.Cleanup(ts.Close)
    64  
    65  	db := &instance.Instance{
    66  		ContextName: "foo",
    67  		Prefix:      "app-test",
    68  	}
    69  
    70  	require.NoError(t, couchdb.ResetDB(db, consts.Apps))
    71  	require.NoError(t, couchdb.ResetDB(db, consts.Konnectors))
    72  	require.NoError(t, couchdb.ResetDB(db, consts.Files))
    73  
    74  	osFS := afero.NewOsFs()
    75  	tmpDir, err := afero.TempDir(osFS, "", "cozy-installer-test")
    76  	if err != nil {
    77  		require.NoError(t, err)
    78  	}
    79  	t.Cleanup(func() { _ = osFS.RemoveAll(tmpDir) })
    80  
    81  	baseFS := afero.NewBasePathFs(osFS, tmpDir)
    82  	fs := appfs.NewAferoCopier(baseFS)
    83  
    84  	require.NoError(t, couchdb.ResetDB(db, consts.Permissions))
    85  
    86  	g, _ := errgroup.WithContext(context.Background())
    87  	couchdb.DefineIndexes(g, db, couchdb.IndexesByDoctype(consts.Files))
    88  	couchdb.DefineIndexes(g, db, couchdb.IndexesByDoctype(consts.Permissions))
    89  
    90  	require.NoError(t, g.Wait())
    91  
    92  	t.Cleanup(func() {
    93  		assert.NoError(t, couchdb.DeleteDB(db, consts.Apps))
    94  		assert.NoError(t, couchdb.DeleteDB(db, consts.Konnectors))
    95  		assert.NoError(t, couchdb.DeleteDB(db, consts.Files))
    96  		assert.NoError(t, couchdb.DeleteDB(db, consts.Permissions))
    97  	})
    98  
    99  	t.Cleanup(func() { assert.NoError(t, localGitCmd.Process.Signal(os.Interrupt)) })
   100  
   101  	t.Run("KonnectorInstallSuccessful", func(t *testing.T) {
   102  		manGen = manifestKonnector
   103  		manName = app.KonnectorManifestName
   104  
   105  		doUpgrade(t, 1)
   106  
   107  		inst, err := app.NewInstaller(db, fs, &app.InstallerOptions{
   108  			Operation: app.Install,
   109  			Type:      consts.KonnectorType,
   110  			Slug:      "local-konnector",
   111  			SourceURL: gitURL,
   112  		})
   113  		require.NoError(t, err)
   114  
   115  		go inst.Run()
   116  
   117  		var state app.State
   118  		var man app.Manifest
   119  		for {
   120  			var done bool
   121  			var err2 error
   122  			man, done, err2 = inst.Poll()
   123  			require.NoError(t, err2)
   124  
   125  			if state == "" {
   126  				if !assert.EqualValues(t, app.Installing, man.State()) {
   127  					return
   128  				}
   129  			} else if state == app.Installing {
   130  				if !assert.EqualValues(t, app.Ready, man.State()) {
   131  					return
   132  				}
   133  				require.True(t, done)
   134  
   135  				break
   136  			} else {
   137  				t.Fatalf("invalid state")
   138  				return
   139  			}
   140  			state = man.State()
   141  		}
   142  
   143  		ok, err := afero.Exists(baseFS, path.Join("/", man.Slug(), man.Version(), app.KonnectorManifestName+".br"))
   144  		assert.NoError(t, err)
   145  		assert.True(t, ok, "The manifest is present")
   146  		ok, err = compressedFileContainsBytes(baseFS, path.Join("/", man.Slug(), man.Version(), app.KonnectorManifestName+".br"), []byte("1.0.0"))
   147  		assert.NoError(t, err)
   148  		assert.True(t, ok, "The manifest has the right version")
   149  
   150  		inst2, err := app.NewInstaller(db, fs, &app.InstallerOptions{
   151  			Operation: app.Install,
   152  			Type:      consts.KonnectorType,
   153  			Slug:      "local-konnector",
   154  			SourceURL: gitURL,
   155  		})
   156  		assert.Nil(t, inst2)
   157  		assert.Equal(t, app.ErrAlreadyExists, err)
   158  	})
   159  
   160  	t.Run("KonnectorUpgradeNotExist", func(t *testing.T) {
   161  		manGen = manifestKonnector
   162  		manName = app.KonnectorManifestName
   163  		inst, err := app.NewInstaller(db, fs, &app.InstallerOptions{
   164  			Operation: app.Update,
   165  			Type:      consts.KonnectorType,
   166  			Slug:      "cozy-konnector-not-exist",
   167  		})
   168  		assert.Nil(t, inst)
   169  		assert.Equal(t, app.ErrNotFound, err)
   170  
   171  		inst, err = app.NewInstaller(db, fs, &app.InstallerOptions{
   172  			Operation: app.Delete,
   173  			Type:      consts.KonnectorType,
   174  			Slug:      "cozy-konnector-not-exist",
   175  		})
   176  		assert.Nil(t, inst)
   177  		assert.Equal(t, app.ErrNotFound, err)
   178  	})
   179  
   180  	t.Run("KonnectorInstallWithUpgrade", func(t *testing.T) {
   181  		manGen = manifestKonnector
   182  		manName = app.KonnectorManifestName
   183  
   184  		doUpgrade(t, 1)
   185  
   186  		inst, err := app.NewInstaller(db, fs, &app.InstallerOptions{
   187  			Operation: app.Install,
   188  			Type:      consts.KonnectorType,
   189  			Slug:      "cozy-konnector-b",
   190  			SourceURL: gitURL,
   191  		})
   192  		require.NoError(t, err)
   193  
   194  		go inst.Run()
   195  
   196  		var man app.Manifest
   197  		for {
   198  			var done bool
   199  			man, done, err = inst.Poll()
   200  			require.NoError(t, err)
   201  
   202  			if done {
   203  				break
   204  			}
   205  		}
   206  
   207  		ok, err := afero.Exists(baseFS, path.Join("/", man.Slug(), man.Version(), app.KonnectorManifestName+".br"))
   208  		assert.NoError(t, err)
   209  		assert.True(t, ok, "The manifest is present")
   210  		ok, err = compressedFileContainsBytes(baseFS, path.Join("/", man.Slug(), man.Version(), app.KonnectorManifestName+".br"), []byte("1.0.0"))
   211  		assert.NoError(t, err)
   212  		assert.True(t, ok, "The manifest has the right version")
   213  
   214  		doUpgrade(t, 2)
   215  
   216  		inst, err = app.NewInstaller(db, fs, &app.InstallerOptions{
   217  			Operation: app.Update,
   218  			Type:      consts.KonnectorType,
   219  			Slug:      "cozy-konnector-b",
   220  		})
   221  		require.NoError(t, err)
   222  
   223  		go inst.Run()
   224  
   225  		var state app.State
   226  		for {
   227  			var done bool
   228  			man, done, err = inst.Poll()
   229  			require.NoError(t, err)
   230  
   231  			if state == "" {
   232  				if !assert.EqualValues(t, app.Upgrading, man.State()) {
   233  					return
   234  				}
   235  			} else if state == app.Upgrading {
   236  				if !assert.EqualValues(t, app.Ready, man.State()) {
   237  					return
   238  				}
   239  				require.True(t, done)
   240  
   241  				break
   242  			} else {
   243  				t.Fatalf("invalid state")
   244  				return
   245  			}
   246  			state = man.State()
   247  		}
   248  
   249  		ok, err = afero.Exists(baseFS, path.Join("/", man.Slug(), man.Version(), app.KonnectorManifestName+".br"))
   250  		assert.NoError(t, err)
   251  		assert.True(t, ok, "The manifest is present")
   252  		ok, err = compressedFileContainsBytes(baseFS, path.Join("/", man.Slug(), man.Version(), app.KonnectorManifestName+".br"), []byte("2.0.0"))
   253  		assert.NoError(t, err)
   254  		assert.True(t, ok, "The manifest has the right version")
   255  	})
   256  
   257  	t.Run("KonnectorUpdateSkipPerms", func(t *testing.T) {
   258  		// Generating test instance
   259  		finished := true
   260  		conf := config.GetConfig()
   261  		conf.Contexts = map[string]interface{}{
   262  			"foocontext": map[string]interface{}{},
   263  		}
   264  
   265  		instance, err := lifecycle.Create(&lifecycle.Options{
   266  			Domain:             "test-skip-perms",
   267  			ContextName:        "foocontext",
   268  			OnboardingFinished: &finished,
   269  		})
   270  
   271  		defer func() { _ = lifecycle.Destroy("test-skip-perms") }()
   272  
   273  		assert.NoError(t, err)
   274  
   275  		manGen = manifestKonnector1
   276  		manName = app.KonnectorManifestName
   277  
   278  		inst, err := app.NewInstaller(instance, fs, &app.InstallerOptions{
   279  			Operation: app.Install,
   280  			Type:      consts.KonnectorType,
   281  			Slug:      "cozy-konnector-test-skip",
   282  			SourceURL: gitURL,
   283  		})
   284  		require.NoError(t, err)
   285  
   286  		var man app.Manifest
   287  
   288  		man, err = inst.RunSync()
   289  		konnManifest := man.(*app.KonnManifest)
   290  		assert.NoError(t, err)
   291  		assert.Empty(t, konnManifest.AvailableVersion())
   292  		assert.Contains(t, konnManifest.Version(), "1.0.0")
   293  
   294  		// Will now update. New perms will be added, preventing an upgrade
   295  		manGen = manifestKonnector2
   296  
   297  		inst, err = app.NewInstaller(instance, fs, &app.InstallerOptions{
   298  			Operation: app.Update,
   299  			Type:      consts.KonnectorType,
   300  			Slug:      "cozy-konnector-test-skip",
   301  		})
   302  		require.NoError(t, err)
   303  
   304  		man, err = inst.RunSync()
   305  		konnManifest = man.(*app.KonnManifest)
   306  		assert.NoError(t, err)
   307  		assert.Contains(t, konnManifest.AvailableVersion(), "2.0.0")
   308  		assert.Contains(t, konnManifest.Version(), "1.0.0") // Assert we stayed on our version
   309  
   310  		// Change configuration to tell we skip the verifications
   311  		conf.Contexts = map[string]interface{}{
   312  			"foocontext": map[string]interface{}{
   313  				"permissions_skip_verification": true,
   314  			},
   315  		}
   316  
   317  		man2, err := inst.RunSync()
   318  		konnManifest = man2.(*app.KonnManifest)
   319  		assert.NoError(t, err)
   320  		// Assert we upgraded version, and the perms have changed
   321  		assert.False(t, man.Permissions().HasSameRules(man2.Permissions()))
   322  		assert.Empty(t, konnManifest.AvailableVersion())
   323  		assert.Contains(t, konnManifest.Version(), "2.0.0")
   324  	})
   325  
   326  	t.Run("KonnectorInstallAndUpgradeWithBranch", func(t *testing.T) {
   327  		manGen = manifestKonnector
   328  		manName = app.KonnectorManifestName
   329  		doUpgrade(t, 3)
   330  
   331  		inst, err := app.NewInstaller(db, fs, &app.InstallerOptions{
   332  			Operation: app.Install,
   333  			Type:      consts.KonnectorType,
   334  			Slug:      "local-konnector-branch",
   335  			SourceURL: gitURL + "#branch",
   336  		})
   337  		require.NoError(t, err)
   338  
   339  		go inst.Run()
   340  
   341  		var state app.State
   342  		var man app.Manifest
   343  		for {
   344  			var done bool
   345  			var err2 error
   346  			man, done, err2 = inst.Poll()
   347  			require.NoError(t, err2)
   348  
   349  			if state == "" {
   350  				if !assert.EqualValues(t, app.Installing, man.State()) {
   351  					return
   352  				}
   353  			} else if state == app.Installing {
   354  				if !assert.EqualValues(t, app.Ready, man.State()) {
   355  					return
   356  				}
   357  				require.True(t, done)
   358  
   359  				break
   360  			} else {
   361  				t.Fatalf("invalid state")
   362  				return
   363  			}
   364  			state = man.State()
   365  		}
   366  
   367  		ok, err := afero.Exists(baseFS, path.Join("/", man.Slug(), man.Version(), app.KonnectorManifestName+".br"))
   368  		assert.NoError(t, err)
   369  		assert.True(t, ok, "The manifest is present")
   370  		ok, err = compressedFileContainsBytes(baseFS, path.Join("/", man.Slug(), man.Version(), app.KonnectorManifestName+".br"), []byte("3.0.0"))
   371  		assert.NoError(t, err)
   372  		assert.True(t, ok, "The manifest has the right version")
   373  
   374  		doUpgrade(t, 4)
   375  
   376  		inst, err = app.NewInstaller(db, fs, &app.InstallerOptions{
   377  			Operation: app.Update,
   378  			Type:      consts.KonnectorType,
   379  			Slug:      "local-konnector-branch",
   380  		})
   381  		require.NoError(t, err)
   382  
   383  		go inst.Run()
   384  
   385  		state = ""
   386  		for {
   387  			var done bool
   388  			var err2 error
   389  			man, done, err2 = inst.Poll()
   390  			require.NoError(t, err2)
   391  
   392  			if state == "" {
   393  				if !assert.EqualValues(t, app.Upgrading, man.State()) {
   394  					return
   395  				}
   396  			} else if state == app.Upgrading {
   397  				if !assert.EqualValues(t, app.Ready, man.State()) {
   398  					return
   399  				}
   400  				require.True(t, done)
   401  
   402  				break
   403  			} else {
   404  				t.Fatalf("invalid state")
   405  				return
   406  			}
   407  			state = man.State()
   408  		}
   409  
   410  		ok, err = afero.Exists(baseFS, path.Join("/", man.Slug(), man.Version(), app.KonnectorManifestName+".br"))
   411  		assert.NoError(t, err)
   412  		assert.True(t, ok, "The manifest is present")
   413  		ok, err = compressedFileContainsBytes(baseFS, path.Join("/", man.Slug(), man.Version(), app.KonnectorManifestName+".br"), []byte("4.0.0"))
   414  		assert.NoError(t, err)
   415  		assert.True(t, ok, "The manifest has the right version")
   416  	})
   417  
   418  	t.Run("KonnectorUninstall", func(t *testing.T) {
   419  		manGen = manifestKonnector
   420  		manName = app.KonnectorManifestName
   421  		inst1, err := app.NewInstaller(db, fs, &app.InstallerOptions{
   422  			Operation: app.Install,
   423  			Type:      consts.KonnectorType,
   424  			Slug:      "konnector-delete",
   425  			SourceURL: gitURL,
   426  		})
   427  		require.NoError(t, err)
   428  
   429  		go inst1.Run()
   430  		for {
   431  			var done bool
   432  			_, done, err = inst1.Poll()
   433  			require.NoError(t, err)
   434  
   435  			if done {
   436  				break
   437  			}
   438  		}
   439  		inst2, err := app.NewInstaller(db, fs, &app.InstallerOptions{
   440  			Operation: app.Delete,
   441  			Type:      consts.KonnectorType,
   442  			Slug:      "konnector-delete",
   443  		})
   444  		require.NoError(t, err)
   445  
   446  		_, err = inst2.RunSync()
   447  		require.NoError(t, err)
   448  
   449  		inst3, err := app.NewInstaller(db, fs, &app.InstallerOptions{
   450  			Operation: app.Delete,
   451  			Type:      consts.KonnectorType,
   452  			Slug:      "konnector-delete",
   453  		})
   454  		assert.Nil(t, inst3)
   455  		assert.Equal(t, app.ErrNotFound, err)
   456  	})
   457  
   458  	t.Run("KonnectorInstallBadType", func(t *testing.T) {
   459  		manGen = manifestWebapp
   460  		manName = app.WebappManifestName
   461  
   462  		inst, err := app.NewInstaller(db, fs, &app.InstallerOptions{
   463  			Operation: app.Install,
   464  			Type:      consts.KonnectorType,
   465  			Slug:      "cozy-bad-type",
   466  			SourceURL: gitURL,
   467  		})
   468  		assert.NoError(t, err)
   469  		_, err = inst.RunSync()
   470  		assert.Error(t, err)
   471  		assert.ErrorIs(t, err, app.ErrInvalidManifestForKonnector)
   472  	})
   473  }
   474  
   475  func compressedFileContainsBytes(fs afero.Fs, filename string, content []byte) (ok bool, err error) {
   476  	f, err := fs.Open(filename)
   477  	if err != nil {
   478  		return
   479  	}
   480  	defer f.Close()
   481  	br := brotli.NewReader(f)
   482  	b, err := io.ReadAll(br)
   483  	if err != nil {
   484  		return
   485  	}
   486  	ok = bytes.Contains(b, content)
   487  	return
   488  }
   489  
   490  func manifestKonnector1() string {
   491  	return `{
   492    "description": "A mini konnector to test cozy-stack-v2",
   493    "type": "node",
   494    "developer": {
   495      "name": "Bruno",
   496      "url": "cozy.io"
   497    },
   498    "license": "MIT",
   499    "name": "mini-app",
   500    "permissions": {
   501  	"bills": {
   502  		"type": "io.cozy.bills"
   503  	}
   504    },
   505    "slug": "mini",
   506    "type": "konnector",
   507    "version": "1.0.0"
   508  }`
   509  }
   510  
   511  func manifestKonnector2() string {
   512  	return `{
   513    "description": "A mini konnector to test cozy-stack-v2",
   514    "type": "node",
   515    "developer": {
   516      "name": "Bruno",
   517      "url": "cozy.io"
   518    },
   519    "license": "MIT",
   520    "name": "mini-app",
   521    "permissions": {
   522  	"bills": {
   523  		"type": "io.cozy.bills"
   524  	},
   525  	"files": {
   526  	  "type": "io.cozy.files"
   527  	}
   528    },
   529    "slug": "mini",
   530    "type": "konnector",
   531    "version": "2.0.0"
   532  }`
   533  }