github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/web/apps/apps_test.go (about)

     1  // spec package is introduced to avoid circular dependencies since this
     2  // particular test requires to depend on routing directly to expose the API and
     3  // the APP server.
     4  package apps_test
     5  
     6  import (
     7  	"archive/tar"
     8  	"bytes"
     9  	"compress/gzip"
    10  	"encoding/json"
    11  	"errors"
    12  	"fmt"
    13  	"io"
    14  	"net/http"
    15  	"net/url"
    16  	"os"
    17  	"path/filepath"
    18  	"testing"
    19  	"time"
    20  
    21  	apps "github.com/cozy/cozy-stack/model/app"
    22  	"github.com/cozy/cozy-stack/model/feature"
    23  	"github.com/cozy/cozy-stack/model/instance"
    24  	"github.com/cozy/cozy-stack/model/instance/lifecycle"
    25  	"github.com/cozy/cozy-stack/model/intent"
    26  	"github.com/cozy/cozy-stack/model/oauth"
    27  	"github.com/cozy/cozy-stack/model/session"
    28  	"github.com/cozy/cozy-stack/model/stack"
    29  	"github.com/cozy/cozy-stack/pkg/assets"
    30  	"github.com/cozy/cozy-stack/pkg/assets/dynamic"
    31  	"github.com/cozy/cozy-stack/pkg/assets/model"
    32  	"github.com/cozy/cozy-stack/pkg/config/config"
    33  	"github.com/cozy/cozy-stack/pkg/consts"
    34  	"github.com/cozy/cozy-stack/pkg/filetype"
    35  	"github.com/cozy/cozy-stack/tests/testutils"
    36  	"github.com/cozy/cozy-stack/web"
    37  	webApps "github.com/cozy/cozy-stack/web/apps"
    38  	"github.com/cozy/cozy-stack/web/auth"
    39  	"github.com/gavv/httpexpect/v2"
    40  	"github.com/labstack/echo/v4"
    41  	"github.com/sirupsen/logrus"
    42  
    43  	"github.com/stretchr/testify/assert"
    44  	"github.com/stretchr/testify/require"
    45  )
    46  
    47  const domain = "cozywithapps.example.net"
    48  
    49  func TestApps(t *testing.T) {
    50  	if testing.Short() {
    51  		t.Skip("an instance is required for this test: test skipped due to the use of --short flag")
    52  	}
    53  
    54  	config.UseTestFile(t)
    55  	config.GetConfig().Assets = "../../assets"
    56  	testutils.NeedCouchdb(t)
    57  	setup := testutils.NewSetup(t, t.Name())
    58  	setup.SetupSwiftTest()
    59  
    60  	require.NoError(t, dynamic.InitDynamicAssetFS(config.FsURL().String()), "Could not init dynamic FS")
    61  	tempdir := t.TempDir()
    62  
    63  	cfg := config.GetConfig()
    64  	cfg.Fs.URL = &url.URL{
    65  		Scheme: "file",
    66  		Host:   "localhost",
    67  		Path:   tempdir,
    68  	}
    69  	cfg.Contexts[config.DefaultInstanceContext] = map[string]interface{}{"manager_url": "http://manager.example.org"}
    70  	was := cfg.Subdomains
    71  	cfg.Subdomains = config.NestedSubdomains
    72  	defer func() { cfg.Subdomains = was }()
    73  
    74  	pass := "aephe2Ei"
    75  	testInstance := setup.GetTestInstance(&lifecycle.Options{Domain: domain})
    76  	params := lifecycle.PassParameters{
    77  		Key:        "fake-encrypt-key",
    78  		Iterations: 0,
    79  	}
    80  	_ = lifecycle.ForceUpdatePassphrase(testInstance, []byte(pass), params)
    81  	testInstance.RegisterToken = nil
    82  	testInstance.OnboardingFinished = true
    83  	_ = instance.Update(testInstance)
    84  
    85  	slug, err := setup.InstallMiniApp()
    86  	require.NoError(t, err, "Could not install mini app")
    87  
    88  	konnectorSlug, err := setup.InstallMiniKonnector()
    89  	require.NoError(t, err, "Could not install mini konnector")
    90  
    91  	clientSideSlug, err := setup.InstallMiniClientSideKonnector()
    92  	require.NoError(t, err, "Could not install miniClientSideKonnector konnector")
    93  
    94  	ts := setup.GetTestServer("/apps", webApps.WebappsRoutes, func(r *echo.Echo) *echo.Echo {
    95  		r.POST("/login", func(c echo.Context) error {
    96  			sess, _ := session.New(testInstance, session.LongRun)
    97  			cookie, _ := sess.ToCookie()
    98  			c.SetCookie(cookie)
    99  			return c.HTML(http.StatusOK, "OK")
   100  		})
   101  		r.POST("/auth/session_code", auth.CreateSessionCode)
   102  		router, err := web.CreateSubdomainProxy(r, &stack.Services{}, webApps.Serve)
   103  		require.NoError(t, err, "Cant start subdoman proxy")
   104  		return router
   105  	})
   106  	t.Cleanup(ts.Close)
   107  
   108  	// Login
   109  	cozysessID := testutils.CreateTestClient(t, ts.URL).POST("/login").
   110  		WithHost(testInstance.Domain).
   111  		Expect().Status(200).
   112  		Cookie("cozysessid").Value().Raw()
   113  
   114  	_, token := setup.GetTestClient(consts.Apps + " " + consts.Konnectors)
   115  
   116  	t.Run("Serve", func(t *testing.T) {
   117  		e := testutils.CreateTestClient(t, ts.URL)
   118  
   119  		assertNotPublic(e, slug, testInstance.Domain, "/foo", 302, "https://cozywithapps.example.net/auth/login?redirect=https%3A%2F%2Fmini.cozywithapps.example.net%2Ffoo")
   120  		assertNotPublic(e, slug, testInstance.Domain, "/foo/hello.tml", 401, "")
   121  
   122  		e = e.Builder(func(r *httpexpect.Request) {
   123  			r.WithCookie("cozysessid", cozysessID)
   124  		})
   125  
   126  		assertAuthGet(e, slug, testInstance.Domain, "/foo/", "text/html", "utf-8", `this is index.html. <a lang="en" href="https://cozywithapps.example.net/status/">Status</a>`)
   127  		assertAuthGet(e, slug, testInstance.Domain, "/foo/hello.html", "text/html", "utf-8", "world {{.Token}}")
   128  		assertAuthGet(e, slug, testInstance.Domain, "/public", "text/html", "utf-8", "this is a file in public/")
   129  		assertAuthGet(e, slug, testInstance.Domain, "/public/index.html", "text/html", "utf-8", "this is a file in public/")
   130  		assertAnonGet(e, slug, testInstance.Domain, "/public", "text/html", "utf-8", "this is a file in public/")
   131  		assertAnonGet(e, slug, testInstance.Domain, "/public/index.html", "text/html", "utf-8", "this is a file in public/")
   132  		assertNotFound(e, slug, testInstance.Domain, "/404")
   133  		assertNotFound(e, slug, testInstance.Domain, "/")
   134  		assertNotFound(e, slug, testInstance.Domain, "/index.html")
   135  		assertNotFound(e, slug, testInstance.Domain, "/public/hello.html")
   136  		assertInternalServerError(e, slug, testInstance.Domain, "/invalid")
   137  	})
   138  
   139  	t.Run("ServeWithClientsLimitExceeded", func(t *testing.T) {
   140  		e := testutils.CreateTestClient(t, ts.URL)
   141  
   142  		// Create the OAuth client for the flagship app
   143  		flagship := oauth.Client{
   144  			RedirectURIs: []string{"cozy://flagship"},
   145  			ClientName:   "flagship-app",
   146  			ClientKind:   "mobile",
   147  			SoftwareID:   "github.com/cozy/cozy-stack/testing/flagship",
   148  			Flagship:     true,
   149  		}
   150  		require.Nil(t, flagship.Create(testInstance, oauth.NotPending))
   151  
   152  		testutils.WithFlag(t, testInstance, "cozy.oauthclients.max", float64(0))
   153  
   154  		e = e.Builder(func(r *httpexpect.Request) {
   155  			r.WithCookie("cozysessid", cozysessID)
   156  		})
   157  
   158  		assertAuthGet(e, slug, testInstance.Domain, "/public", "text/html", "utf-8", "this is a file in public/")
   159  		assertAnonGet(e, slug, testInstance.Domain, "/public", "text/html", "utf-8", "this is a file in public/")
   160  
   161  		redirect := testInstance.SubDomain(slug)
   162  		redirect.Path = "/foo"
   163  		location := testInstance.PageURL("/settings/clients/limit-exceeded", url.Values{"redirect": {redirect.String()}})
   164  		assertRedirect(e, slug, testInstance.Domain, "/foo", 303, location)
   165  
   166  		assertAuthGet(e, slug, testInstance.Domain, "/foo/hello.html", "text/html", "utf-8", "world {{.Token}}")
   167  
   168  		testInstance.FeatureFlags = map[string]interface{}{}
   169  		require.NoError(t, instance.Update(testInstance))
   170  	})
   171  
   172  	t.Run("CozyBar", func(t *testing.T) {
   173  		e := testutils.CreateTestClient(t, ts.URL)
   174  
   175  		e.GET("/bar/").
   176  			WithHost(slug+"."+testInstance.Domain).
   177  			WithCookie("cozysessid", cozysessID).
   178  			WithRedirectPolicy(httpexpect.DontFollowRedirects).
   179  			Expect().Status(200).
   180  			HasContentType("text/html", "utf-8").
   181  			Body().
   182  			Contains(`<link rel="stylesheet" type="text/css" href="//cozywithapps.example.net/assets/css/cozy-bar`).
   183  			Contains(`<script src="//cozywithapps.example.net/assets/js/cozy-bar`)
   184  	})
   185  
   186  	t.Run("Warnings", func(t *testing.T) {
   187  		e := testutils.CreateTestClient(t, ts.URL)
   188  
   189  		// Moved instance warning
   190  
   191  		testInstance.Moved = true
   192  		require.NoError(t, instance.Update(testInstance))
   193  
   194  		e.GET("/foo/").
   195  			WithHost(slug+"."+testInstance.Domain).
   196  			WithCookie("cozysessid", cozysessID).
   197  			WithRedirectPolicy(httpexpect.DontFollowRedirects).
   198  			Expect().Status(200).
   199  			HasContentType("text/html", "utf-8").
   200  			Body().
   201  			Contains(`<meta name="user-action-required" data-title="Cozy has been moved" data-code="moved" data-detail="The Cozy has been moved to a new address"`)
   202  
   203  		testInstance.Moved = false
   204  		require.NoError(t, instance.Update(testInstance))
   205  
   206  		// TOS not signed warning
   207  
   208  		testutils.WithManager(t, testInstance)
   209  
   210  		tosSigned := testInstance.TOSSigned
   211  		tosLatest := testInstance.TOSLatest
   212  		tomorrow := time.Now().Add(24 * time.Hour)
   213  		testInstance.TOSSigned = "1.0.0-20170901"
   214  		testInstance.TOSLatest = "2.0.0-" + tomorrow.Format("20060102")
   215  		require.NoError(t, instance.Update(testInstance))
   216  
   217  		notSigned, deadline := testInstance.CheckTOSNotSignedAndDeadline()
   218  		require.True(t, notSigned)
   219  		require.Equal(t, deadline, instance.TOSWarning)
   220  
   221  		tosLink, err := testInstance.ManagerURL(instance.ManagerTOSURL)
   222  		require.NoError(t, err)
   223  		require.NotEmpty(t, tosLink)
   224  
   225  		e.GET("/foo/").
   226  			WithHost(slug+"."+testInstance.Domain).
   227  			WithCookie("cozysessid", cozysessID).
   228  			WithRedirectPolicy(httpexpect.DontFollowRedirects).
   229  			Expect().Status(200).
   230  			HasContentType("text/html", "utf-8").
   231  			Body().
   232  			Contains(`<meta name="user-action-required" data-title="TOS Updated" data-code="tos-updated" data-detail="Terms of services have been updated" data-links="` + tosLink + `"`)
   233  
   234  		testInstance.TOSSigned = tosSigned
   235  		testInstance.TOSLatest = tosLatest
   236  		require.NoError(t, instance.Update(testInstance))
   237  	})
   238  
   239  	t.Run("ServeWithAnIntents", func(t *testing.T) {
   240  		e := testutils.CreateTestClient(t, ts.URL)
   241  
   242  		intent := &intent.Intent{
   243  			Action: "PICK",
   244  			Type:   "io.cozy.foos",
   245  			Client: "io.cozy.apps/test-app",
   246  		}
   247  		err := intent.Save(testInstance)
   248  		require.NoError(t, err)
   249  		err = intent.FillServices(testInstance)
   250  		require.NoError(t, err)
   251  		require.Len(t, intent.Services, 1)
   252  		err = intent.Save(testInstance)
   253  		require.NoError(t, err)
   254  
   255  		u, err := url.Parse(intent.Services[0].Href)
   256  		require.NoError(t, err)
   257  
   258  		e.GET(u.Path).
   259  			WithHost(slug+"."+testInstance.Domain).
   260  			WithQueryString(u.RawQuery).
   261  			WithCookie("cozysessid", cozysessID).
   262  			Expect().Status(200).
   263  			Header(echo.HeaderContentSecurityPolicy).
   264  			Contains("frame-ancestors 'self' https://test-app.cozywithapps.example.net/;")
   265  	})
   266  
   267  	t.Run("FaviconWithContext", func(t *testing.T) {
   268  		e := testutils.CreateTestClient(t, ts.URL)
   269  
   270  		context := "foo"
   271  
   272  		asset, ok := assets.Get("/favicon.ico", context)
   273  		if ok {
   274  			_ = assets.Remove(asset.Name, asset.Context)
   275  		}
   276  		// Create and insert an asset in foo context
   277  		tmpdir := t.TempDir()
   278  		_, err := os.OpenFile(filepath.Join(tmpdir, "custom_favicon.png"), os.O_RDWR|os.O_CREATE, 0600)
   279  		assert.NoError(t, err)
   280  
   281  		assetsOptions := []model.AssetOption{{
   282  			URL:     fmt.Sprintf("file://%s", filepath.Join(tmpdir, "custom_favicon.png")),
   283  			Name:    "/favicon.ico",
   284  			Context: context,
   285  		}}
   286  		err = dynamic.RegisterCustomExternals(assetsOptions, 1)
   287  		assert.NoError(t, err)
   288  
   289  		// Test the theme
   290  		assert.NoError(t, lifecycle.Patch(testInstance, &lifecycle.Options{
   291  			ContextName: context,
   292  		}))
   293  		assert.NoError(t, err)
   294  
   295  		e.GET("/foo").
   296  			WithHost(slug+"."+testInstance.Domain).
   297  			WithCookie("cozysessid", cozysessID).
   298  			WithRedirectPolicy(httpexpect.DontFollowRedirects).
   299  			Expect().Status(200).
   300  			Body().
   301  			Contains(`this is index.html. <a lang="en" href="https://cozywithapps.example.net/status/">Status</a>`).
   302  			Contains(fmt.Sprintf("/assets/ext/%s/favicon.ico", context)).
   303  			NotContains("/assets/favicon.ico")
   304  	})
   305  
   306  	t.Run("SessionCode", func(t *testing.T) {
   307  		e := testutils.CreateTestClient(t, ts.URL)
   308  
   309  		// Create the OAuth client for the flagship app
   310  		flagship := oauth.Client{
   311  			RedirectURIs: []string{"cozy://flagship"},
   312  			ClientName:   "flagship-app",
   313  			SoftwareID:   "github.com/cozy/cozy-stack/testing/flagship",
   314  			Flagship:     true,
   315  		}
   316  		assert.Nil(t, flagship.Create(testInstance, oauth.NotPending))
   317  
   318  		// Create a maximal permission for it
   319  		token, err := testInstance.MakeJWT(consts.AccessTokenAudience,
   320  			flagship.ClientID, "*", "", time.Now())
   321  		assert.NoError(t, err)
   322  
   323  		// Create the session code
   324  		code := e.POST("/auth/session_code").
   325  			WithHost(testInstance.Domain).
   326  			WithHeader("Authorization", "Bearer "+token).
   327  			Expect().Status(201).
   328  			JSON().Object().
   329  			Value("session_code").String().NotEmpty().Raw()
   330  
   331  		// Load a non-public page
   332  		e.GET("/foo/").
   333  			WithQuery("session_code", code).
   334  			WithHost(slug+"."+testInstance.Domain).
   335  			WithCookie("cozysessid", cozysessID).
   336  			Expect().Status(200).
   337  			Body().Contains("this is index.html")
   338  
   339  		// Try again and check that the session code cannot be reused
   340  		e.GET("/foo/").
   341  			WithQuery("session_code", code).
   342  			WithHost(slug + "." + testInstance.Domain).
   343  			WithRedirectPolicy(httpexpect.DontFollowRedirects).
   344  			Expect().Status(302).
   345  			Header("Location").Contains("/auth/login")
   346  	})
   347  
   348  	t.Run("ServeAppsWithJWTNotLogged", func(t *testing.T) {
   349  		e := testutils.CreateTestClient(t, ts.URL)
   350  
   351  		config.GetConfig().Subdomains = config.FlatSubdomains
   352  		appHost := "cozywithapps-mini.example.net"
   353  
   354  		rawURL := e.GET("/foo").
   355  			WithQuery("jwt", "abc").
   356  			WithHost(appHost).
   357  			WithRedirectPolicy(httpexpect.DontFollowRedirects).
   358  			WithCookie("cozysessid", cozysessID).
   359  			Expect().Status(302).
   360  			Header("Location").Raw()
   361  
   362  		location, err := url.Parse(rawURL)
   363  		require.NoError(t, err)
   364  
   365  		assert.Equal(t, "/auth/login", location.Path)
   366  		assert.Equal(t, testInstance.Domain, location.Host)
   367  		assert.NotEmpty(t, location.Query().Get("redirect"))
   368  		assert.Equal(t, "abc", location.Query().Get("jwt"))
   369  	})
   370  
   371  	t.Run("OauthAppCantInstallApp", func(t *testing.T) {
   372  		e := testutils.CreateTestClient(t, ts.URL)
   373  
   374  		e.POST("/apps/mini-bis").
   375  			WithHost(testInstance.Domain).
   376  			WithQuery("Source", "git://github.com/nono/cozy-mini.git").
   377  			WithHeader("Authorization", "Bearer "+token).
   378  			WithCookie("cozysessid", cozysessID).
   379  			Expect().Status(403)
   380  	})
   381  
   382  	t.Run("OauthAppCantUpdateApp", func(t *testing.T) {
   383  		e := testutils.CreateTestClient(t, ts.URL)
   384  
   385  		e.PUT("/apps/mini").
   386  			WithHost(testInstance.Domain).
   387  			WithHeader("Authorization", "Bearer "+token).
   388  			WithCookie("cozysessid", cozysessID).
   389  			Expect().Status(403)
   390  	})
   391  
   392  	t.Run("ListApps", func(t *testing.T) {
   393  		e := testutils.CreateTestClient(t, ts.URL)
   394  
   395  		obj := e.GET("/apps/").
   396  			WithHost(testInstance.Domain).
   397  			WithHeader("Authorization", "Bearer "+token).
   398  			WithCookie("cozysessid", cozysessID).
   399  			Expect().Status(200).
   400  			JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   401  			Object()
   402  
   403  		data := obj.Value("data").Array()
   404  		data.Length().IsEqual(1)
   405  
   406  		elem := data.Value(0).Object()
   407  		elem.Value("id").String().NotEmpty()
   408  		elem.HasValue("type", "io.cozy.apps")
   409  
   410  		attrs := elem.Value("attributes").Object()
   411  		attrs.HasValue("name", "Mini")
   412  		attrs.HasValue("slug", "mini")
   413  
   414  		links := elem.Value("links").Object()
   415  		links.HasValue("self", "/apps/mini")
   416  		links.HasValue("related", "https://cozywithapps-mini.example.net/")
   417  		links.HasValue("icon", "/apps/mini/icon/1.0.0")
   418  	})
   419  
   420  	t.Run("IconForApp", func(t *testing.T) {
   421  		e := testutils.CreateTestClient(t, ts.URL)
   422  
   423  		e.GET("/apps/mini/icon").
   424  			WithHost(testInstance.Domain).
   425  			WithHeader("Authorization", "Bearer "+token).
   426  			Expect().Status(200).
   427  			Body().IsEqual("<svg>...</svg>")
   428  	})
   429  
   430  	t.Run("DownloadApp", func(t *testing.T) {
   431  		e := testutils.CreateTestClient(t, ts.URL)
   432  
   433  		res := e.GET("/apps/mini/download").
   434  			WithHost(testInstance.Domain).
   435  			WithHeader("Authorization", "Bearer "+token).
   436  			Expect().Status(200).
   437  			Raw()
   438  
   439  		mimeType, reader := filetype.FromReader(res.Body)
   440  		require.Equal(t, "application/gzip", mimeType)
   441  		gr, err := gzip.NewReader(reader)
   442  		require.NoError(t, err)
   443  		tr := tar.NewReader(gr)
   444  		indexFound := false
   445  		for {
   446  			header, err := tr.Next()
   447  			if errors.Is(err, io.EOF) {
   448  				break
   449  			}
   450  			require.NoError(t, err)
   451  			if header.Name == "/index.html" {
   452  				indexFound = true
   453  			}
   454  		}
   455  		require.True(t, indexFound)
   456  	})
   457  
   458  	t.Run("DownloadKonnectorVersion", func(t *testing.T) {
   459  		e := testutils.CreateTestClient(t, ts.URL)
   460  
   461  		res := e.GET("/konnectors/mini/download/1.0.0").
   462  			WithHost(testInstance.Domain).
   463  			WithHeader("Authorization", "Bearer "+token).
   464  			Expect().Status(200).
   465  			Raw()
   466  
   467  		mimeType, reader := filetype.FromReader(res.Body)
   468  		require.Equal(t, "application/gzip", mimeType)
   469  		gr, err := gzip.NewReader(reader)
   470  		require.NoError(t, err)
   471  		tr := tar.NewReader(gr)
   472  		iconFound := false
   473  		for {
   474  			header, err := tr.Next()
   475  			if errors.Is(err, io.EOF) {
   476  				break
   477  			}
   478  			require.NoError(t, err)
   479  			if header.Name == "/icon.svg" {
   480  				iconFound = true
   481  			}
   482  		}
   483  		require.True(t, iconFound)
   484  	})
   485  
   486  	t.Run("OpenWebapp", func(t *testing.T) {
   487  		e := testutils.CreateTestClient(t, ts.URL)
   488  
   489  		// Expected flags since they can be modified by other tests
   490  		flags, err := feature.GetFlags(testInstance)
   491  		require.NoError(t, err)
   492  		flagsStr, err := json.Marshal(flags)
   493  		require.NoError(t, err)
   494  
   495  		// Create the OAuth client for the flagship app
   496  		flagship := oauth.Client{
   497  			RedirectURIs: []string{"cozy://flagship"},
   498  			ClientName:   "flagship-app",
   499  			SoftwareID:   "github.com/cozy/cozy-stack/testing/flagship",
   500  			Flagship:     true,
   501  		}
   502  		require.Nil(t, flagship.Create(testInstance, oauth.NotPending))
   503  
   504  		// Create a maximal permission for it
   505  		token, err := testInstance.MakeJWT(consts.AccessTokenAudience,
   506  			flagship.ClientID, "*", "", time.Now())
   507  		require.NoError(t, err)
   508  
   509  		obj := e.GET("/apps/mini/open").
   510  			WithHost(testInstance.Domain).
   511  			WithHeader("Authorization", "Bearer "+token).
   512  			Expect().Status(200).
   513  			JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   514  			Object()
   515  
   516  		data := obj.Value("data").Object()
   517  		data.Value("id").String().NotEmpty()
   518  		data.HasValue("type", "io.cozy.apps.open")
   519  
   520  		attrs := data.Value("attributes").Object()
   521  		attrs.HasValue("AppName", "Mini")
   522  		attrs.HasValue("AppSlug", "mini")
   523  		attrs.HasValue("IconPath", "icon.svg")
   524  		attrs.HasValue("Tracking", "false")
   525  		attrs.HasValue("SubDomain", "flat")
   526  		attrs.Value("Cookie").String().Contains("HttpOnly")
   527  		attrs.Value("Token").String().NotEmpty()
   528  		attrs.HasValue("Flags", string(flagsStr))
   529  		attrs.ContainsKey("Warnings")
   530  
   531  		links := data.Value("links").Object()
   532  		links.HasValue("self", "/apps/mini/open")
   533  	})
   534  
   535  	t.Run("UninstallAppWithLinkedClient", func(t *testing.T) {
   536  		e := testutils.CreateTestClient(t, ts.URL)
   537  
   538  		// Install drive app
   539  		installer, err := apps.NewInstaller(testInstance, apps.Copier(consts.WebappType, testInstance),
   540  			&apps.InstallerOptions{
   541  				Operation:  apps.Install,
   542  				Type:       consts.WebappType,
   543  				Slug:       "drive",
   544  				SourceURL:  "registry://drive",
   545  				Registries: testInstance.Registries(),
   546  			},
   547  		)
   548  		require.NoError(t, err)
   549  
   550  		_, err = installer.RunSync()
   551  		require.NoError(t, err)
   552  
   553  		// Link an OAuthClient to drive
   554  		oauthClient := &oauth.Client{
   555  			ClientName:   "test-linked",
   556  			RedirectURIs: []string{"https://foobar"},
   557  			SoftwareID:   "registry://drive",
   558  		}
   559  
   560  		oauthClient.Create(testInstance)
   561  		// Forcing the oauthClient to get a couchID for the purpose of later deletion
   562  		oauthClient, err = oauth.FindClient(testInstance, oauthClient.ClientID)
   563  		require.NoError(t, err)
   564  
   565  		scope := "io.cozy.apps:ALL"
   566  		token, err := testInstance.MakeJWT("cli", "drive", scope, "", time.Now())
   567  		require.NoError(t, err)
   568  
   569  		// Trying to remove this app
   570  		e.DELETE("/apps/drive").
   571  			WithHost(testInstance.Domain).
   572  			WithHeader("Authorization", "Bearer "+token).
   573  			Expect().Status(400).
   574  			Body().Contains("linked OAuth client exists")
   575  
   576  		// Cleaning
   577  		uninstaller, err := apps.NewInstaller(testInstance, apps.Copier(consts.WebappType, testInstance),
   578  			&apps.InstallerOptions{
   579  				Operation:  apps.Delete,
   580  				Type:       consts.WebappType,
   581  				Slug:       "drive",
   582  				SourceURL:  "registry://drive",
   583  				Registries: testInstance.Registries(),
   584  			},
   585  		)
   586  		assert.NoError(t, err)
   587  		_, err = uninstaller.RunSync()
   588  		assert.NoError(t, err)
   589  		errc := oauthClient.Delete(testInstance)
   590  		assert.Nil(t, errc)
   591  	})
   592  
   593  	t.Run("UninstallAppWithoutLinkedClient", func(t *testing.T) {
   594  		e := testutils.CreateTestClient(t, ts.URL)
   595  
   596  		// Install drive app
   597  		installer, err := apps.NewInstaller(testInstance, apps.Copier(consts.WebappType, testInstance),
   598  			&apps.InstallerOptions{
   599  				Operation:  apps.Install,
   600  				Type:       consts.WebappType,
   601  				Slug:       "drive",
   602  				SourceURL:  "registry://drive",
   603  				Registries: testInstance.Registries(),
   604  			},
   605  		)
   606  		require.NoError(t, err)
   607  
   608  		_, err = installer.RunSync()
   609  		require.NoError(t, err)
   610  
   611  		// Create an OAuth client not linked to drive
   612  		oauthClient := &oauth.Client{
   613  			ClientName:   "test-linked",
   614  			RedirectURIs: []string{"https://foobar"},
   615  			SoftwareID:   "foobarclient",
   616  		}
   617  		oauthClient.Create(testInstance)
   618  		// Forcing the oauthClient to get a couchID for the purpose of later deletion
   619  		oauthClient, err = oauth.FindClient(testInstance, oauthClient.ClientID)
   620  		require.NoError(t, err)
   621  
   622  		scope := "io.cozy.apps:ALL"
   623  		token, err := testInstance.MakeJWT("cli", "drive", scope, "", time.Now())
   624  		assert.NoError(t, err)
   625  
   626  		// Trying to remove this app
   627  		e.DELETE("/apps/drive").
   628  			WithHost(testInstance.Domain).
   629  			WithHeader("Authorization", "Bearer "+token).
   630  			Expect().Status(200)
   631  
   632  		// Cleaning
   633  		errc := oauthClient.Delete(testInstance)
   634  		assert.Nil(t, errc)
   635  	})
   636  
   637  	t.Run("SendKonnectorLogsFromFlagshipApp", func(t *testing.T) {
   638  		e := testutils.CreateTestClient(t, ts.URL)
   639  
   640  		initialOutput := logrus.New().Out
   641  		defer logrus.SetOutput(initialOutput)
   642  
   643  		testOutput := new(bytes.Buffer)
   644  		logrus.SetOutput(testOutput)
   645  
   646  		// Create the OAuth client for the flagship app
   647  		flagship := oauth.Client{
   648  			RedirectURIs: []string{"cozy://flagship"},
   649  			ClientName:   "flagship-app",
   650  			SoftwareID:   "github.com/cozy/cozy-stack/testing/flagship",
   651  			Flagship:     true,
   652  		}
   653  		require.Nil(t, flagship.Create(testInstance, oauth.NotPending))
   654  
   655  		// Give it the maximal permission
   656  		token, err := testInstance.MakeJWT(consts.AccessTokenAudience, flagship.ClientID, "*", "", time.Now())
   657  		require.NoError(t, err)
   658  
   659  		// Send logs for a client side konnector
   660  		e.POST("/konnectors/"+clientSideSlug+"/logs").
   661  			WithHost(testInstance.Domain).
   662  			WithHeader("Authorization", "Bearer "+token).
   663  			WithBytes([]byte(`[ { "timestamp": "2022-10-27T17:13:38.382Z", "level": "error", "msg": "This is an error message" } ]`)).
   664  			Expect().Status(204)
   665  
   666  		assert.Equal(t, `time="2022-10-27T17:13:38.382Z" level=error msg="This is an error message" domain=`+domain+" job_id= nspace=jobs slug="+clientSideSlug+" worker_id=client\n", testOutput.String())
   667  
   668  		// Send logs for a konnector
   669  		testOutput.Reset()
   670  		e.POST("/konnectors/"+konnectorSlug+"/logs").
   671  			WithHost(testInstance.Domain).
   672  			WithHeader("Authorization", "Bearer "+token).
   673  			WithBytes([]byte(`[ { "timestamp": "2022-10-27T17:13:38.382Z", "level": "error", "msg": "This is an error message" } ]`)).
   674  			Expect().Status(204)
   675  
   676  		assert.Equal(t, `time="2022-10-27T17:13:38.382Z" level=error msg="This is an error message" domain=`+domain+" job_id= nspace=jobs slug="+konnectorSlug+"\n", testOutput.String())
   677  
   678  		// Send logs for a webapp
   679  		testOutput.Reset()
   680  		e.POST("/apps/"+slug+"/logs").
   681  			WithHost(testInstance.Domain).
   682  			WithHeader("Authorization", "Bearer "+token).
   683  			WithBytes([]byte(`[ { "timestamp": "2022-10-27T17:13:38.382Z", "level": "error", "msg": "This is an error message" } ]`)).
   684  			Expect().Status(204)
   685  
   686  		assert.Equal(t, `time="2022-10-27T17:13:38.382Z" level=error msg="This is an error message" domain=`+domain+" job_id= nspace=jobs slug="+slug+"\n", testOutput.String())
   687  	})
   688  
   689  	t.Run("SendKonnectorLogsFromKonnector", func(t *testing.T) {
   690  		e := testutils.CreateTestClient(t, ts.URL)
   691  
   692  		initialOutput := logrus.New().Out
   693  		defer logrus.SetOutput(initialOutput)
   694  
   695  		testOutput := new(bytes.Buffer)
   696  		logrus.SetOutput(testOutput)
   697  
   698  		token := testInstance.BuildKonnectorToken(slug)
   699  
   700  		e.POST("/konnectors/"+slug+"/logs").
   701  			WithHost(testInstance.Domain).
   702  			WithHeader("Authorization", "Bearer "+token).
   703  			WithBytes([]byte(`[ { "timestamp": "2022-10-27T17:13:38.382Z", "level": "error", "msg": "This is an error message" } ]`)).
   704  			Expect().Status(204)
   705  
   706  		assert.Equal(t, `time="2022-10-27T17:13:38.382Z" level=error msg="This is an error message" domain=`+domain+" job_id= nspace=jobs slug="+slug+"\n", testOutput.String())
   707  
   708  		// Sending logs for a webapp should fail
   709  		e.POST("/apps/"+slug+"/logs").
   710  			WithHost(testInstance.Domain).
   711  			WithHeader("Authorization", "Bearer "+token).
   712  			WithBytes([]byte(`[ { "timestamp": "2022-10-27T17:13:38.382Z", "level": "error", "msg": "This is an error message" } ]`)).
   713  			Expect().Status(403)
   714  	})
   715  
   716  	t.Run("SendAppLogsFromWebApp", func(t *testing.T) {
   717  		e := testutils.CreateTestClient(t, ts.URL)
   718  
   719  		initialOutput := logrus.New().Out
   720  		defer logrus.SetOutput(initialOutput)
   721  
   722  		testOutput := new(bytes.Buffer)
   723  		logrus.SetOutput(testOutput)
   724  
   725  		token := testInstance.BuildAppToken(slug, "")
   726  
   727  		e.POST("/apps/"+slug+"/logs").
   728  			WithHost(testInstance.Domain).
   729  			WithHeader("Authorization", "Bearer "+token).
   730  			WithBytes([]byte(`[ { "timestamp": "2022-10-27T17:13:38.382Z", "level": "error", "msg": "This is an error message" } ]`)).
   731  			Expect().Status(204)
   732  
   733  		assert.Equal(t, `time="2022-10-27T17:13:38.382Z" level=error msg="This is an error message" domain=`+domain+" job_id= nspace=jobs slug="+slug+"\n", testOutput.String())
   734  
   735  		// Sending logs for a konnector should fail
   736  		e.POST("/konnectors/"+slug+"/logs").
   737  			WithHost(testInstance.Domain).
   738  			WithHeader("Authorization", "Bearer "+token).
   739  			WithBytes([]byte(`[ { "timestamp": "2022-10-27T17:13:38.382Z", "level": "error", "msg": "This is an error message" } ]`)).
   740  			Expect().Status(403)
   741  	})
   742  }
   743  
   744  func assertAuthGet(e *httpexpect.Expect, slug, domain, path, contentType, charset, content string) {
   745  	e.GET(path).
   746  		WithHost(slug+"."+domain).
   747  		Expect().Status(200).
   748  		HasContentType(contentType, charset).
   749  		Body().Contains(content)
   750  }
   751  
   752  func assertAnonGet(e *httpexpect.Expect, slug, domain, path, contentType, charset, content string) {
   753  	e.GET(path).
   754  		WithHost(slug+"."+domain).
   755  		Expect().Status(200).
   756  		HasContentType(contentType, charset).
   757  		Body().Contains(content)
   758  }
   759  
   760  func assertNotPublic(e *httpexpect.Expect, slug, domain, path string, code int, location string) {
   761  	e.GET(path).
   762  		WithHost(slug + "." + domain).
   763  		WithRedirectPolicy(httpexpect.DontFollowRedirects).
   764  		Expect().Status(code).
   765  		Header("Location").IsEqual(location)
   766  }
   767  
   768  func assertNotFound(e *httpexpect.Expect, slug, domain, path string) {
   769  	e.GET(path).
   770  		WithHost(slug + "." + domain).
   771  		WithRedirectPolicy(httpexpect.DontFollowRedirects).
   772  		Expect().Status(404)
   773  }
   774  
   775  func assertInternalServerError(e *httpexpect.Expect, slug, domain, path string) {
   776  	e.GET(path).
   777  		WithHost(slug + "." + domain).
   778  		Expect().Status(500)
   779  }
   780  
   781  func assertRedirect(e *httpexpect.Expect, slug, domain, path string, code int, location string) {
   782  	e.GET(path).
   783  		WithHost(slug + "." + domain).
   784  		WithRedirectPolicy(httpexpect.DontFollowRedirects).
   785  		Expect().Status(code).
   786  		Header("Location").IsEqual(location)
   787  }