github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/tests/testutils/test_utils.go (about) 1 package testutils 2 3 import ( 4 "bytes" 5 "context" 6 "errors" 7 "flag" 8 "fmt" 9 "io" 10 "net/http/httptest" 11 "net/url" 12 "path" 13 "testing" 14 "time" 15 16 "github.com/andybalholm/brotli" 17 apps "github.com/cozy/cozy-stack/model/app" 18 "github.com/cozy/cozy-stack/model/instance" 19 "github.com/cozy/cozy-stack/model/instance/lifecycle" 20 "github.com/cozy/cozy-stack/model/oauth" 21 "github.com/cozy/cozy-stack/model/permission" 22 "github.com/cozy/cozy-stack/model/stack" 23 "github.com/cozy/cozy-stack/model/vfs" 24 "github.com/cozy/cozy-stack/pkg/assets/dynamic" 25 "github.com/cozy/cozy-stack/pkg/config/config" 26 "github.com/cozy/cozy-stack/pkg/consts" 27 "github.com/cozy/cozy-stack/pkg/couchdb" 28 "github.com/cozy/cozy-stack/pkg/utils" 29 "github.com/gavv/httpexpect/v2" 30 "github.com/gofrs/uuid/v5" 31 "github.com/labstack/echo/v4" 32 "github.com/ncw/swift/v2/swifttest" 33 "github.com/spf13/viper" 34 "github.com/stretchr/testify/require" 35 ) 36 37 // This flag avoid starting the stack twice. 38 var stackStarted bool 39 var useDebug bool 40 41 func init() { 42 flag.BoolVar(&useDebug, "debug", false, "display the requests content") 43 44 if useDebug { 45 useDebug = true 46 } 47 } 48 49 // CreateTestClient setup an httpexpect.Expect client used to make http tests. 50 // 51 // This init take allow to use the `--debug` flag in your tests in order to 52 // print the requests/responses content. 53 // 54 // example: `go test ./web/permissions --debug`. 55 func CreateTestClient(t testing.TB, url string) *httpexpect.Expect { 56 var printer httpexpect.Printer 57 58 t.Helper() 59 60 flag.Parse() 61 62 if useDebug { 63 printer = httpexpect.NewDebugPrinter(t, true) 64 } else { 65 printer = httpexpect.NewCompactPrinter(t) 66 } 67 68 return httpexpect.WithConfig(httpexpect.Config{ 69 TestName: t.Name(), 70 BaseURL: url, 71 Reporter: httpexpect.NewAssertReporter(t), 72 Printers: []httpexpect.Printer{printer}, 73 }) 74 } 75 76 // NeedCouchdb kill the process if there is no couchdb running 77 func NeedCouchdb(t *testing.T) { 78 if _, err := couchdb.CheckStatus(context.Background()); err != nil { 79 t.Fatal("This test need couchdb to run.") 80 } 81 } 82 83 // TODO can be used as a reminder to do something in the future. The test that 84 // calls TODO will fail after the limit date, which is an efficient way to not 85 // forget about it. 86 func TODO(t *testing.T, date string, args ...interface{}) { 87 now := time.Now() 88 limit, err := time.Parse("2006-01-02", date) 89 if err != nil { 90 t.Errorf("Invalid date for TODO: %s", err) 91 } else if now.After(limit) { 92 t.Error(args...) 93 } 94 } 95 96 // TestSetup is a wrapper around a testing.M which handles 97 // setting up instance, client, VFSContext, testserver 98 // and cleaning up after itself 99 type TestSetup struct { 100 t testing.TB 101 name string 102 host string 103 inst *instance.Instance 104 ts *httptest.Server 105 cleanup func() 106 } 107 108 // NewSetup returns a new TestSetup 109 // name is used to prevent bug when tests are run in parallel 110 func NewSetup(t testing.TB, name string) *TestSetup { 111 setup := TestSetup{ 112 name: name, 113 t: t, 114 host: name + "_" + utils.RandomString(10) + ".cozy.local", 115 cleanup: func() {}, 116 } 117 118 t.Cleanup(setup.cleanup) 119 120 return &setup 121 } 122 123 // SetupSwiftTest can be used to start an in-memory Swift server for tests. 124 func (c *TestSetup) SetupSwiftTest() { 125 swiftSrv, err := swifttest.NewSwiftServer("localhost") 126 require.NoError(c.t, err, "failed to create swift server") 127 128 viper.Set("swift.username", "swifttest") 129 viper.Set("swift.api_key", "swifttest") 130 viper.Set("swift.auth_url", swiftSrv.AuthURL) 131 132 swiftURL := &url.URL{ 133 Scheme: "swift", 134 Host: "localhost", 135 RawQuery: "UserName=swifttest&Password=swifttest&AuthURL=" + url.QueryEscape(swiftSrv.AuthURL), 136 } 137 138 err = config.InitSwiftConnection(config.Fs{ 139 URL: swiftURL, 140 }) 141 require.NoError(c.t, err, "Could not init swift connection.") 142 viper.Set("fs.url", swiftURL.String()) 143 144 ctx := context.Background() 145 err = config.GetSwiftConnection().ContainerCreate(ctx, dynamic.DynamicAssetsContainerName, nil) 146 require.NoError(c.t, err, "Could not create dynamic container.") 147 } 148 149 // GetTestInstance creates an instance with a random host 150 // The instance will be removed on container cleanup 151 func (c *TestSetup) GetTestInstance(opts ...*lifecycle.Options) *instance.Instance { 152 if c.inst != nil { 153 return c.inst 154 } 155 var err error 156 if !stackStarted { 157 _, _, err = stack.Start(stack.NoGops, stack.NoDynAssets) 158 require.NoError(c.t, err, "Error while starting job system") 159 stackStarted = true 160 } 161 if len(opts) == 0 { 162 opts = []*lifecycle.Options{{}} 163 } 164 if opts[0].Domain == "" { 165 opts[0].Domain = c.host 166 } else { 167 c.host = opts[0].Domain 168 } 169 err = lifecycle.Destroy(c.host) 170 if err != nil && !errors.Is(err, instance.ErrNotFound) { 171 require.NoError(c.t, err, "Error while destroying instance") 172 } 173 174 i, err := lifecycle.Create(opts[0]) 175 require.NoError(c.t, err, "Cannot create test instance") 176 177 c.t.Cleanup(func() { _ = lifecycle.Destroy(i.Domain) }) 178 c.inst = i 179 return i 180 } 181 182 // GetTestClient creates an oauth client and associated token 183 func (c *TestSetup) GetTestClient(scopes string) (*oauth.Client, string) { 184 inst := c.GetTestInstance() 185 client := oauth.Client{ 186 RedirectURIs: []string{"http://localhost/oauth/callback"}, 187 ClientName: "client-" + c.host, 188 SoftwareID: "github.com/cozy/cozy-stack/testing/" + c.name, 189 } 190 client.Create(inst, oauth.NotPending) 191 token, err := c.inst.MakeJWT(consts.AccessTokenAudience, client.ClientID, scopes, "", time.Now()) 192 require.NoError(c.t, err, "Cannot create oauth token") 193 194 return &client, token 195 } 196 197 // GetTestAdminClient creates an oauth client and associated token with access to admin routes 198 func (c *TestSetup) GetTestAdminClient() (*oauth.Client, string) { 199 inst := c.GetTestInstance() 200 client := oauth.Client{ 201 RedirectURIs: []string{"http://localhost/oauth/callback"}, 202 ClientName: "client-" + c.host, 203 SoftwareID: "github.com/cozy/cozy-stack/testing/" + c.name, 204 } 205 client.Create(inst, oauth.NotPending) 206 token, err := c.inst.MakeJWT(consts.CLIAudience, client.ClientID, "*", "", time.Now()) 207 require.NoError(c.t, err, "Cannot create oauth token") 208 209 return &client, token 210 } 211 212 // stupidRenderer is a renderer for echo that does nothing. 213 // It is used just to avoid the error "Renderer not registered" for rendering 214 // error pages. 215 type stupidRenderer struct{} 216 217 func (sr *stupidRenderer) Render(w io.Writer, name string, data interface{}, c echo.Context) error { 218 return nil 219 } 220 221 // GetTestServer start a testServer with a single group on prefix 222 // The server will be closed on container cleanup 223 func (c *TestSetup) GetTestServer(prefix string, routes func(*echo.Group), 224 mws ...func(*echo.Echo) *echo.Echo) *httptest.Server { 225 return c.GetTestServerMultipleRoutes(map[string]func(*echo.Group){prefix: routes}, mws...) 226 } 227 228 // GetTestServerMultipleRoutes starts a testServer and creates a group for each 229 // pair of (prefix, routes) given. 230 // The server will be closed on container cleanup. 231 func (c *TestSetup) GetTestServerMultipleRoutes(mpr map[string]func(*echo.Group), mws ...func(*echo.Echo) *echo.Echo) *httptest.Server { 232 handler := echo.New() 233 234 for prefix, routes := range mpr { 235 group := handler.Group(prefix, func(next echo.HandlerFunc) echo.HandlerFunc { 236 return func(context echo.Context) error { 237 context.Set("instance", c.inst) 238 return next(context) 239 } 240 }) 241 242 routes(group) 243 } 244 245 for _, mw := range mws { 246 handler = mw(handler) 247 } 248 handler.Renderer = &stupidRenderer{} 249 ts := httptest.NewServer(handler) 250 c.t.Cleanup(ts.Close) 251 c.ts = ts 252 return ts 253 } 254 255 func (c *TestSetup) InstallMiniApp() (string, error) { 256 slug := "mini" 257 instance := c.GetTestInstance() 258 c.t.Cleanup(func() { _ = permission.DestroyWebapp(instance, slug) }) 259 260 permissions := permission.Set{ 261 permission.Rule{ 262 Type: "io.cozy.apps.logs", 263 Verbs: permission.Verbs(permission.POST), 264 }, 265 } 266 version := "1.0.0" 267 manifest := &couchdb.JSONDoc{ 268 Type: consts.Apps, 269 M: map[string]interface{}{ 270 "_id": consts.Apps + "/" + slug, 271 "name": "Mini", 272 "icon": "icon.svg", 273 "slug": slug, 274 "source": "git://github.com/cozy/mini.git", 275 "state": apps.Ready, 276 "intents": []apps.Intent{ 277 { 278 Action: "PICK", 279 Types: []string{"io.cozy.foos"}, 280 Href: "/foo", 281 }, 282 }, 283 "routes": apps.Routes{ 284 "/foo": apps.Route{ 285 Folder: "/", 286 Index: "index.html", 287 Public: false, 288 }, 289 "/bar": apps.Route{ 290 Folder: "/bar", 291 Index: "index.html", 292 Public: false, 293 }, 294 "/public": apps.Route{ 295 Folder: "/public", 296 Index: "index.html", 297 Public: true, 298 }, 299 "/invalid": apps.Route{ 300 Folder: "/", 301 Index: "invalid.html", 302 Public: false, 303 }, 304 }, 305 "permissions": permissions, 306 "version": version, 307 }, 308 } 309 310 err := couchdb.CreateNamedDoc(instance, manifest) 311 if err != nil { 312 return "", err 313 } 314 315 _, err = permission.CreateWebappSet(instance, slug, permissions, version) 316 if err != nil { 317 return "", err 318 } 319 320 appdir := path.Join(vfs.WebappsDirName, slug, version) 321 _, err = vfs.MkdirAll(instance.VFS(), appdir) 322 if err != nil { 323 return "", err 324 } 325 bardir := path.Join(appdir, "bar") 326 _, err = vfs.Mkdir(instance.VFS(), bardir, nil) 327 if err != nil { 328 return "", err 329 } 330 pubdir := path.Join(appdir, "public") 331 _, err = vfs.Mkdir(instance.VFS(), pubdir, nil) 332 if err != nil { 333 return "", err 334 } 335 336 err = createFile(instance, appdir, "icon.svg", "<svg>...</svg>") 337 if err != nil { 338 return "", err 339 } 340 err = createFile(instance, appdir, "index.html", `<html><body>this is index.html. <a lang="{{.Locale}}" href="https://{{.Domain}}/status/">Status</a> {{.Favicon}}</body></html>`) 341 if err != nil { 342 return "", err 343 } 344 err = createFile(instance, bardir, "index.html", "{{.CozyBar}}") 345 if err != nil { 346 return "", err 347 } 348 err = createFile(instance, appdir, "hello.html", "world {{.Token}}") 349 if err != nil { 350 return "", err 351 } 352 err = createFile(instance, pubdir, "index.html", "this is a file in public/") 353 if err != nil { 354 return "", err 355 } 356 err = createFile(instance, appdir, "invalid.html", "this is invalid.html. {{.InvalidHelper}}") 357 return slug, err 358 } 359 360 func (c *TestSetup) InstallMiniKonnector() (string, error) { 361 slug := "mini" 362 instance := c.GetTestInstance() 363 c.t.Cleanup(func() { _ = permission.DestroyKonnector(instance, slug) }) 364 365 permissions := permission.Set{ 366 permission.Rule{ 367 Type: "io.cozy.apps.logs", 368 Verbs: permission.Verbs(permission.POST), 369 }, 370 } 371 version := "1.0.0" 372 manifest := &couchdb.JSONDoc{ 373 Type: consts.Konnectors, 374 M: map[string]interface{}{ 375 "_id": consts.Konnectors + "/" + slug, 376 "name": "Mini", 377 "icon": "icon.svg", 378 "slug": slug, 379 "source": "git://github.com/cozy/mini.git", 380 "state": apps.Ready, 381 "permissions": permissions, 382 "version": version, 383 }, 384 } 385 386 err := couchdb.CreateNamedDoc(instance, manifest) 387 if err != nil { 388 return "", err 389 } 390 391 _, err = permission.CreateKonnectorSet(instance, slug, permissions, version) 392 if err != nil { 393 return "", err 394 } 395 396 konnDir := path.Join(vfs.KonnectorsDirName, slug, version) 397 _, err = vfs.MkdirAll(instance.VFS(), konnDir) 398 if err != nil { 399 return "", err 400 } 401 402 err = createFile(instance, konnDir, "icon.svg", "<svg>...</svg>") 403 return slug, err 404 } 405 406 func (c *TestSetup) InstallMiniClientSideKonnector() (string, error) { 407 slug := "mini-client-side-konnector" 408 instance := c.GetTestInstance() 409 c.t.Cleanup(func() { _ = permission.DestroyKonnector(instance, slug) }) 410 411 permissions := permission.Set{ 412 permission.Rule{ 413 Type: "io.cozy.apps.logs", 414 Verbs: permission.Verbs(permission.POST), 415 }, 416 } 417 version := "1.0.0" 418 manifest := &couchdb.JSONDoc{ 419 Type: consts.Konnectors, 420 M: map[string]interface{}{ 421 "_id": consts.Konnectors + "/" + slug, 422 "name": "Mini", 423 "icon": "icon.svg", 424 "slug": slug, 425 "source": "git://github.com/cozy/mini.git", 426 "state": apps.Ready, 427 "permissions": permissions, 428 "version": version, 429 "clientSide": true, 430 }, 431 } 432 433 err := couchdb.CreateNamedDoc(instance, manifest) 434 if err != nil { 435 return "", err 436 } 437 438 _, err = permission.CreateKonnectorSet(instance, slug, permissions, version) 439 if err != nil { 440 return "", err 441 } 442 443 konnDir := path.Join(vfs.KonnectorsDirName, slug, version) 444 _, err = vfs.MkdirAll(instance.VFS(), konnDir) 445 if err != nil { 446 return "", err 447 } 448 449 err = createFile(instance, konnDir, "icon.svg", "<svg>...</svg>") 450 return slug, err 451 } 452 453 func createFile(instance *instance.Instance, dir, filename, content string) error { 454 abs := path.Join(dir, filename+".br") 455 file, err := vfs.Create(instance.VFS(), abs) 456 if err != nil { 457 return err 458 } 459 defer file.Close() 460 _, err = file.Write(compress(content)) 461 return err 462 } 463 464 func compress(content string) []byte { 465 buf := &bytes.Buffer{} 466 bw := brotli.NewWriter(buf) 467 _, _ = bw.Write([]byte(content)) 468 _ = bw.Close() 469 return buf.Bytes() 470 } 471 472 func WithManager(t *testing.T, inst *instance.Instance) (shouldRemoveUUID bool) { 473 if inst.UUID == "" { 474 uuid, err := uuid.NewV7() 475 require.NoError(t, err, "Could not enable test instance manager") 476 inst.UUID = uuid.String() 477 shouldRemoveUUID = true 478 } 479 480 config, ok := inst.SettingsContext() 481 require.True(t, ok, "Could not enable test instance manager: could not fetch test instance settings context") 482 483 managerURL, ok := config["manager_url"].(string) 484 require.True(t, ok, "Could not enable test instance manager: manager_url config is required") 485 require.NotEmpty(t, managerURL, "Could not enable test instance manager: manager_url config is required") 486 487 was := config["enable_premium_links"] 488 config["enable_premium_links"] = true 489 490 t.Cleanup(func() { 491 config["enable_premium_links"] = was 492 493 if shouldRemoveUUID { 494 inst.UUID = "" 495 require.NoError(t, instance.Update(inst)) 496 } 497 }) 498 499 err := instance.Update(inst) 500 require.NoError(t, err, "Could not enable test instance manager") 501 502 return shouldRemoveUUID 503 } 504 505 func DisableManager(inst *instance.Instance, shouldRemoveUUID bool) error { 506 config, ok := inst.SettingsContext() 507 if !ok { 508 return fmt.Errorf("Could not disable test instance manager: could not fetch test instance settings context") 509 } 510 511 config["enable_premium_links"] = false 512 513 if shouldRemoveUUID { 514 inst.UUID = "" 515 return instance.Update(inst) 516 } 517 return nil 518 } 519 520 func WithFlag(t *testing.T, inst *instance.Instance, name string, value interface{}) { 521 flags := inst.FeatureFlags 522 if flags == nil { 523 flags = map[string]interface{}{} 524 } 525 526 was := flags[name] 527 528 flags[name] = value 529 inst.FeatureFlags = flags 530 err := instance.Update(inst) 531 require.NoError(t, err, "Could not set %s flag value to %v", name, value) 532 533 t.Cleanup(func() { 534 flags[name] = was 535 inst.FeatureFlags = flags 536 require.NoError(t, instance.Update(inst)) 537 }) 538 }