github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/worker/exec/konnector_test.go (about)

     1  package exec
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"os"
     7  	"path"
     8  	"strings"
     9  	"sync"
    10  	"testing"
    11  
    12  	"github.com/cozy/cozy-stack/model/account"
    13  	"github.com/cozy/cozy-stack/model/app"
    14  	"github.com/cozy/cozy-stack/model/job"
    15  	"github.com/cozy/cozy-stack/model/permission"
    16  	"github.com/cozy/cozy-stack/model/vfs"
    17  	"github.com/cozy/cozy-stack/pkg/config/config"
    18  	"github.com/cozy/cozy-stack/pkg/consts"
    19  	"github.com/cozy/cozy-stack/pkg/couchdb"
    20  	"github.com/cozy/cozy-stack/pkg/crypto"
    21  	"github.com/cozy/cozy-stack/pkg/i18n"
    22  	"github.com/cozy/cozy-stack/pkg/metadata"
    23  	"github.com/cozy/cozy-stack/pkg/prefixer"
    24  	"github.com/cozy/cozy-stack/pkg/realtime"
    25  	"github.com/cozy/cozy-stack/tests/testutils"
    26  	jwt "github.com/golang-jwt/jwt/v5"
    27  	"github.com/spf13/afero"
    28  	"github.com/stretchr/testify/assert"
    29  	"github.com/stretchr/testify/require"
    30  )
    31  
    32  func TestExecKonnector(t *testing.T) {
    33  	if testing.Short() {
    34  		t.Skip("a couchdb is required for this test: test skipped due to the use of --short flag")
    35  	}
    36  
    37  	config.UseTestFile(t)
    38  	require.NoError(t, loadLocale(), "Could not load default locale translations")
    39  
    40  	setup := testutils.NewSetup(t, t.Name())
    41  
    42  	inst := setup.GetTestInstance()
    43  	fs := inst.VFS()
    44  
    45  	t.Run("with unknown domain", func(t *testing.T) {
    46  		msg, err := job.NewMessage(map[string]interface{}{
    47  			"konnector": "unknownapp",
    48  		})
    49  		assert.NoError(t, err)
    50  		db := prefixer.NewPrefixer(0, "instance.does.not.exist", "instance.does.not.exist")
    51  		j := job.NewJob(db, &job.JobRequest{
    52  			Message:    msg,
    53  			WorkerType: "konnector",
    54  		})
    55  		ctx, cancel := job.NewTaskContext("id", j, nil)
    56  		defer cancel()
    57  		ctx = ctx.WithCookie(&konnectorWorker{})
    58  		err = worker(ctx)
    59  		assert.Error(t, err)
    60  		assert.Equal(t, "Instance not found", err.Error())
    61  	})
    62  
    63  	t.Run("with unknown app", func(t *testing.T) {
    64  		msg, err := job.NewMessage(map[string]interface{}{
    65  			"konnector": "unknownapp",
    66  		})
    67  		assert.NoError(t, err)
    68  		j := job.NewJob(inst, &job.JobRequest{
    69  			Message:    msg,
    70  			WorkerType: "konnector",
    71  		})
    72  		ctx, cancel := job.NewTaskContext("id", j, inst)
    73  		defer cancel()
    74  		ctx = ctx.WithCookie(&konnectorWorker{})
    75  		err = worker(ctx)
    76  		assert.Error(t, err)
    77  		assert.Equal(t, "Application is not installed", err.Error())
    78  	})
    79  
    80  	t.Run("with a bad file exec", func(t *testing.T) {
    81  		folderToSave := "7890"
    82  
    83  		installer, err := app.NewInstaller(inst, app.Copier(consts.KonnectorType, inst),
    84  			&app.InstallerOptions{
    85  				Operation: app.Install,
    86  				Type:      consts.KonnectorType,
    87  				Slug:      "my-konnector-1",
    88  				SourceURL: "git://github.com/konnectors/cozy-konnector-trainline.git",
    89  			},
    90  		)
    91  		require.NoError(t, err)
    92  
    93  		_, err = installer.RunSync()
    94  		require.NoError(t, err)
    95  
    96  		msg, err := job.NewMessage(map[string]interface{}{
    97  			"konnector":      "my-konnector-1",
    98  			"folder_to_save": folderToSave,
    99  		})
   100  		assert.NoError(t, err)
   101  
   102  		j := job.NewJob(inst, &job.JobRequest{
   103  			Message:    msg,
   104  			WorkerType: "konnector",
   105  		})
   106  
   107  		config.GetConfig().Konnectors.Cmd = ""
   108  		ctx, cancel := job.NewTaskContext("id", j, inst)
   109  		defer cancel()
   110  		ctx = ctx.WithCookie(&konnectorWorker{})
   111  		err = worker(ctx)
   112  		assert.Error(t, err)
   113  		assert.Contains(t, err.Error(), "exec")
   114  
   115  		config.GetConfig().Konnectors.Cmd = "echo"
   116  		err = worker(ctx)
   117  		assert.NoError(t, err)
   118  	})
   119  
   120  	t.Run("success", func(t *testing.T) {
   121  		script := `#!/bin/bash
   122  
   123  echo "{\"type\": \"toto\", \"message\": \"COZY_URL=${COZY_URL} ${COZY_CREDENTIALS}\"}"
   124  echo "bad json"
   125  echo "{\"type\": \"manifest\", \"message\": \"$(ls ${1}/manifest.konnector)\" }"
   126  >&2 echo "log error"
   127  `
   128  		osFs := afero.NewOsFs()
   129  		tmpScript := fmt.Sprintf("/tmp/test-konn-%d.sh", os.Getpid())
   130  		defer func() { _ = osFs.RemoveAll(tmpScript) }()
   131  
   132  		err := afero.WriteFile(osFs, tmpScript, []byte(script), 0)
   133  		require.NoError(t, err)
   134  
   135  		err = osFs.Chmod(tmpScript, 0777)
   136  		require.NoError(t, err)
   137  
   138  		installer, err := app.NewInstaller(inst, app.Copier(consts.KonnectorType, inst),
   139  			&app.InstallerOptions{
   140  				Operation: app.Install,
   141  				Type:      consts.KonnectorType,
   142  				Slug:      "my-konnector-1",
   143  				SourceURL: "git://github.com/konnectors/cozy-konnector-trainline.git",
   144  			},
   145  		)
   146  		if !errors.Is(err, app.ErrAlreadyExists) {
   147  			require.NoError(t, err)
   148  
   149  			_, err = installer.RunSync()
   150  			require.NoError(t, err)
   151  		}
   152  
   153  		var wg sync.WaitGroup
   154  		wg.Add(1)
   155  
   156  		go func() {
   157  			evCh := realtime.GetHub().Subscriber(inst)
   158  			evCh.Subscribe(consts.JobEvents)
   159  			wg.Done()
   160  			ch := evCh.Channel
   161  			ev1 := <-ch
   162  			ev2 := <-ch
   163  			evCh.Close()
   164  			doc1 := ev1.Doc.(*couchdb.JSONDoc)
   165  			doc2 := ev2.Doc.(*couchdb.JSONDoc)
   166  
   167  			assert.Equal(t, inst.Domain, ev1.Domain)
   168  			assert.Equal(t, inst.Domain, ev2.Domain)
   169  
   170  			assert.Equal(t, "toto", doc1.M["type"])
   171  			assert.Equal(t, "manifest", doc2.M["type"])
   172  
   173  			msg2 := doc2.M["message"].(string)
   174  			assert.True(t, strings.HasPrefix(msg2, "/tmp"))
   175  			assert.True(t, strings.HasSuffix(msg2, "/manifest.konnector"))
   176  
   177  			msg1 := doc1.M["message"].(string)
   178  			cozyURL := "COZY_URL=" + inst.PageURL("/", nil) + " "
   179  			assert.True(t, strings.HasPrefix(msg1, cozyURL))
   180  			token := msg1[len(cozyURL):]
   181  			var claims permission.Claims
   182  			err2 := crypto.ParseJWT(token, func(t *jwt.Token) (interface{}, error) {
   183  				return inst.PickKey(t.Claims.(*permission.Claims).Audience[0])
   184  			}, &claims)
   185  			assert.NoError(t, err2)
   186  			assert.Equal(t, consts.KonnectorAudience, claims.Audience[0])
   187  			wg.Done()
   188  		}()
   189  
   190  		wg.Wait()
   191  		wg.Add(1)
   192  		msg, err := job.NewMessage(map[string]interface{}{
   193  			"konnector": "my-konnector-1",
   194  		})
   195  		assert.NoError(t, err)
   196  
   197  		j := job.NewJob(inst, &job.JobRequest{
   198  			Message:    msg,
   199  			WorkerType: "konnector",
   200  		})
   201  
   202  		config.GetConfig().Konnectors.Cmd = tmpScript
   203  		ctx, cancel := job.NewTaskContext("id", j, inst)
   204  		defer cancel()
   205  		ctx = ctx.WithCookie(&konnectorWorker{})
   206  		err = worker(ctx)
   207  		assert.NoError(t, err)
   208  
   209  		wg.Wait()
   210  	})
   211  
   212  	t.Run("with secret from accountType", func(t *testing.T) {
   213  		script := `#!/bin/bash
   214  
   215  SECRET=$(echo "$COZY_PARAMETERS" | sed -e 's/.*secret"://' -e 's/[},].*//')
   216  echo "{\"type\": \"params\", \"message\": ${SECRET} }"
   217  `
   218  		osFs := afero.NewOsFs()
   219  		tmpScript := fmt.Sprintf("/tmp/test-konn-%d.sh", os.Getpid())
   220  		defer func() { _ = osFs.RemoveAll(tmpScript) }()
   221  
   222  		err := afero.WriteFile(osFs, tmpScript, []byte(script), 0)
   223  		require.NoError(t, err)
   224  
   225  		err = osFs.Chmod(tmpScript, 0777)
   226  		require.NoError(t, err)
   227  
   228  		at := &account.AccountType{
   229  			GrantMode: account.SecretGrant,
   230  			Slug:      "my-konnector-1",
   231  			Secret:    "s3cr3t",
   232  		}
   233  		err = couchdb.CreateDoc(prefixer.SecretsPrefixer, at)
   234  		assert.NoError(t, err)
   235  		defer func() {
   236  			// Clean the account types
   237  			ats, _ := account.FindAccountTypesBySlug("my-konnector-1", "all-contexts")
   238  			for _, at = range ats {
   239  				_ = couchdb.DeleteDoc(prefixer.SecretsPrefixer, at)
   240  			}
   241  		}()
   242  
   243  		installer, err := app.NewInstaller(inst, app.Copier(consts.KonnectorType, inst),
   244  			&app.InstallerOptions{
   245  				Operation: app.Install,
   246  				Type:      consts.KonnectorType,
   247  				Slug:      "my-konnector-1",
   248  				SourceURL: "git://github.com/konnectors/cozy-konnector-trainline.git",
   249  			},
   250  		)
   251  		if !errors.Is(err, app.ErrAlreadyExists) {
   252  			require.NoError(t, err)
   253  
   254  			_, err = installer.RunSync()
   255  			require.NoError(t, err)
   256  		}
   257  
   258  		var wg sync.WaitGroup
   259  		wg.Add(1)
   260  
   261  		go func() {
   262  			evCh := realtime.GetHub().Subscriber(inst)
   263  			evCh.Subscribe(consts.JobEvents)
   264  			wg.Done()
   265  			ch := evCh.Channel
   266  			ev1 := <-ch
   267  			evCh.Close()
   268  			doc1 := ev1.Doc.(*couchdb.JSONDoc)
   269  
   270  			assert.Equal(t, inst.Domain, ev1.Domain)
   271  			assert.Equal(t, "params", doc1.M["type"])
   272  			msg1 := doc1.M["message"]
   273  			assert.Equal(t, "s3cr3t", msg1)
   274  			wg.Done()
   275  		}()
   276  
   277  		wg.Wait()
   278  		wg.Add(1)
   279  		msg, err := job.NewMessage(map[string]interface{}{
   280  			"konnector": "my-konnector-1",
   281  		})
   282  		assert.NoError(t, err)
   283  
   284  		j := job.NewJob(inst, &job.JobRequest{
   285  			Message:    msg,
   286  			WorkerType: "konnector",
   287  		})
   288  
   289  		config.GetConfig().Konnectors.Cmd = tmpScript
   290  		ctx, cancel := job.NewTaskContext("id", j, inst)
   291  		defer cancel()
   292  		ctx = ctx.WithCookie(&konnectorWorker{})
   293  		err = worker(ctx)
   294  		assert.NoError(t, err)
   295  
   296  		wg.Wait()
   297  	})
   298  
   299  	t.Run("create folder", func(t *testing.T) {
   300  		script := `#!/bin/bash
   301  
   302  echo "{\"type\": \"toto\", \"message\": \"COZY_URL=${COZY_URL}\"}"
   303  `
   304  		osFs := afero.NewOsFs()
   305  		tmpScript := fmt.Sprintf("/tmp/test-konn-%d.sh", os.Getpid())
   306  		defer func() { _ = osFs.RemoveAll(tmpScript) }()
   307  
   308  		err := afero.WriteFile(osFs, tmpScript, []byte(script), 0)
   309  		require.NoError(t, err)
   310  
   311  		err = osFs.Chmod(tmpScript, 0777)
   312  		require.NoError(t, err)
   313  
   314  		installer, err := app.NewInstaller(inst, app.Copier(consts.KonnectorType, inst),
   315  			&app.InstallerOptions{
   316  				Operation: app.Install,
   317  				Type:      consts.KonnectorType,
   318  				Slug:      "my-konnector-1",
   319  				SourceURL: "git://github.com/konnectors/cozy-konnector-trainline.git",
   320  			},
   321  		)
   322  		if !errors.Is(err, app.ErrAlreadyExists) {
   323  			require.NoError(t, err)
   324  
   325  			_, err = installer.RunSync()
   326  			require.NoError(t, err)
   327  		}
   328  
   329  		var wg sync.WaitGroup
   330  		wg.Add(1)
   331  
   332  		go func() {
   333  			evCh := realtime.GetHub().Subscriber(inst)
   334  			evCh.Subscribe(consts.Files)
   335  			wg.Done()
   336  			ch := evCh.Channel
   337  
   338  			// for DefaultFolderPath
   339  			for ev := range ch {
   340  				doc := ev.Doc.(*vfs.DirDoc)
   341  				if doc.DocName == "toto" {
   342  					assert.Equal(t, inst.Domain, ev.Domain)
   343  					wg.Done()
   344  					break
   345  				}
   346  			}
   347  
   348  			// for Konnector name and Account name
   349  			for ev := range ch {
   350  				doc := ev.Doc.(*vfs.DirDoc)
   351  				if doc.DocName == "account-1" {
   352  					assert.Equal(t, inst.Domain, ev.Domain)
   353  					wg.Done()
   354  					break
   355  				}
   356  			}
   357  		}()
   358  
   359  		wg.Wait()
   360  
   361  		acc := &account.Account{
   362  			Metadata: &metadata.CozyMetadata{
   363  				SourceIdentifier: "identifier1",
   364  			},
   365  		}
   366  
   367  		// Folder is created from DefaultFolderPath
   368  		wg.Add(1)
   369  		acc.DefaultFolderPath = "/Administrative/toto"
   370  		require.NoError(t, couchdb.CreateDoc(inst, acc))
   371  		defer func() { _ = couchdb.DeleteDoc(inst, acc) }()
   372  
   373  		msg, err := job.NewMessage(map[string]interface{}{
   374  			"konnector":      "my-konnector-1",
   375  			"folder_to_save": "id-of-a-deleted-folder",
   376  			"account":        acc.ID(),
   377  		})
   378  		require.NoError(t, err)
   379  
   380  		j := job.NewJob(inst, &job.JobRequest{
   381  			Message:    msg,
   382  			WorkerType: "konnector",
   383  		})
   384  
   385  		config.GetConfig().Konnectors.Cmd = tmpScript
   386  		ctx, cancel := job.NewTaskContext("id", j, inst)
   387  		defer cancel()
   388  		ctx = ctx.WithCookie(&konnectorWorker{})
   389  		err = worker(ctx)
   390  		require.NoError(t, err)
   391  
   392  		wg.Wait()
   393  
   394  		dir, err := fs.DirByPath("/Administrative/toto")
   395  		require.NoError(t, err)
   396  		require.Len(t, dir.ReferencedBy, 2)
   397  		assert.Equal(t, dir.ReferencedBy[0].Type, "io.cozy.konnectors")
   398  		assert.Equal(t, dir.ReferencedBy[0].ID, "io.cozy.konnectors/my-konnector-1")
   399  		assert.Equal(t, dir.ReferencedBy[1].Type, "io.cozy.accounts.sourceAccountIdentifier")
   400  		assert.Equal(t, dir.ReferencedBy[1].ID, "identifier1")
   401  		assert.Equal(t, "my-konnector-1", dir.CozyMetadata.CreatedByApp)
   402  		assert.Contains(t, dir.CozyMetadata.CreatedOn, inst.Domain)
   403  		assert.Len(t, dir.CozyMetadata.UpdatedByApps, 1)
   404  		assert.Equal(t, dir.CozyMetadata.SourceAccount, acc.ID())
   405  		require.NoError(t, fs.DestroyDirAndContent(dir, fs.EnsureErased))
   406  
   407  		// Folder is created from Konnector name and Account name
   408  		wg.Add(1)
   409  		acc.DefaultFolderPath = ""
   410  		acc.Name = "account-1"
   411  		require.NoError(t, couchdb.UpdateDoc(inst, acc))
   412  
   413  		msg, err = job.NewMessage(map[string]interface{}{
   414  			"konnector":      "my-konnector-1",
   415  			"folder_to_save": "id-of-a-deleted-folder",
   416  			"account":        acc.ID(),
   417  		})
   418  		require.NoError(t, err)
   419  
   420  		j = job.NewJob(inst, &job.JobRequest{
   421  			Message:    msg,
   422  			WorkerType: "konnector",
   423  		})
   424  
   425  		origCmd := config.GetConfig().Konnectors.Cmd
   426  		config.GetConfig().Konnectors.Cmd = tmpScript
   427  		defer func() { config.GetConfig().Konnectors.Cmd = origCmd }()
   428  
   429  		ctx, cancel = job.NewTaskContext("id", j, inst)
   430  		defer cancel()
   431  		ctx = ctx.WithCookie(&konnectorWorker{})
   432  		err = worker(ctx)
   433  		require.NoError(t, err)
   434  
   435  		wg.Wait()
   436  
   437  		dir, err = fs.DirByPath("/Administrative/Trainline/account-1")
   438  		require.NoError(t, err)
   439  		require.Len(t, dir.ReferencedBy, 2)
   440  		assert.Equal(t, dir.ReferencedBy[0].Type, "io.cozy.konnectors")
   441  		assert.Equal(t, dir.ReferencedBy[0].ID, "io.cozy.konnectors/my-konnector-1")
   442  		assert.Equal(t, dir.ReferencedBy[1].Type, "io.cozy.accounts.sourceAccountIdentifier")
   443  		assert.Equal(t, dir.ReferencedBy[1].ID, "identifier1")
   444  		assert.Equal(t, dir.ReferencedBy[0].ID, "io.cozy.konnectors/my-konnector-1")
   445  		assert.Equal(t, "my-konnector-1", dir.CozyMetadata.CreatedByApp)
   446  		assert.Contains(t, dir.CozyMetadata.CreatedOn, inst.Domain)
   447  		assert.Len(t, dir.CozyMetadata.UpdatedByApps, 1)
   448  		assert.Equal(t, dir.CozyMetadata.SourceAccount, acc.ID())
   449  
   450  		var updatedAcc account.Account
   451  		err = couchdb.GetDoc(inst, consts.Accounts, acc.ID(), &updatedAcc)
   452  		require.NoError(t, err)
   453  		assert.Equal(t, updatedAcc.DefaultFolderPath, "/Administrative/Trainline/account-1")
   454  	})
   455  }
   456  
   457  func TestBeforeHookKonnector(t *testing.T) {
   458  	if testing.Short() {
   459  		t.Skip("a couchdb is required for this test: test skipped due to the use of --short flag")
   460  	}
   461  
   462  	config.UseTestFile(t)
   463  	require.NoError(t, loadLocale(), "Could not load default locale translations")
   464  
   465  	setup := testutils.NewSetup(t, t.Name())
   466  	slug, err := setup.InstallMiniKonnector()
   467  	require.NoError(t, err)
   468  
   469  	inst := setup.GetTestInstance()
   470  
   471  	t.Run("stack maintenance", func(t *testing.T) {
   472  		err := app.ActivateMaintenance(slug, nil)
   473  		require.NoError(t, err)
   474  
   475  		msg, err := job.NewMessage(map[string]interface{}{
   476  			"konnector": slug,
   477  		})
   478  		require.NoError(t, err)
   479  
   480  		j := job.NewJob(inst, &job.JobRequest{
   481  			Message:    msg,
   482  			WorkerType: "konnector",
   483  		})
   484  
   485  		shouldExec, _ := beforeHookKonnector(j)
   486  		assert.False(t, shouldExec)
   487  
   488  		testutils.WithFlag(t, inst, "harvest.skip-maintenance-for", map[string]interface{}{"list": []string{slug}})
   489  		shouldExec, _ = beforeHookKonnector(j)
   490  		assert.True(t, shouldExec)
   491  	})
   492  }
   493  
   494  func loadLocale() error {
   495  	locale := consts.DefaultLocale
   496  	assetsPath := config.GetConfig().Assets
   497  	if assetsPath != "" {
   498  		pofile := path.Join("../..", assetsPath, "locales", locale+".po")
   499  		po, err := os.ReadFile(pofile)
   500  		if err != nil {
   501  			return fmt.Errorf("Can't load the po file for %s", locale)
   502  		}
   503  		i18n.LoadLocale(locale, "", po)
   504  	}
   505  	return nil
   506  }