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 }