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  }