Ctrl K
ComunidadGuías…

web/apps/apps_test.go

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// spec package is introduced to avoid circular dependencies since this
// particular test requires to depend on routing directly to expose the API and
// the APP server.
package apps_test

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"encoding/json"
	"errors"
	"fmt"
	"html"
	"io"
	"net"
	"net/http"
	"net/http/httptest"
	"net/http/httputil"
	"net/url"
	"os"
	"path/filepath"
	"testing"
	"time"

	apps "github.com/cozy/cozy-stack/model/app"
	"github.com/cozy/cozy-stack/model/feature"
	"github.com/cozy/cozy-stack/model/instance"
	"github.com/cozy/cozy-stack/model/instance/lifecycle"
	"github.com/cozy/cozy-stack/model/intent"
	"github.com/cozy/cozy-stack/model/oauth"
	"github.com/cozy/cozy-stack/model/permission"
	"github.com/cozy/cozy-stack/model/publiclink"
	"github.com/cozy/cozy-stack/model/session"
	"github.com/cozy/cozy-stack/model/stack"
	"github.com/cozy/cozy-stack/model/vfs"
	"github.com/cozy/cozy-stack/pkg/assets"
	"github.com/cozy/cozy-stack/pkg/assets/dynamic"
	"github.com/cozy/cozy-stack/pkg/assets/model"
	"github.com/cozy/cozy-stack/pkg/config/config"
	"github.com/cozy/cozy-stack/pkg/consts"
	"github.com/cozy/cozy-stack/pkg/couchdb"
	"github.com/cozy/cozy-stack/pkg/couchdb/mango"
	"github.com/cozy/cozy-stack/pkg/crypto"
	"github.com/cozy/cozy-stack/pkg/filetype"
	"github.com/cozy/cozy-stack/pkg/utils"
	"github.com/cozy/cozy-stack/tests/testutils"
	"github.com/cozy/cozy-stack/web"
	webApps "github.com/cozy/cozy-stack/web/apps"
	"github.com/cozy/cozy-stack/web/auth"
	"github.com/gavv/httpexpect/v2"
	"github.com/labstack/echo/v4"
	"github.com/sirupsen/logrus"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

const domain = "cozywithapps.example.net"

func TestApps(t *testing.T) {
	if testing.Short() {
		t.Skip("an instance is required for this test: test skipped due to the use of --short flag")
	}

	config.UseTestFile(t)
	config.GetConfig().Assets = "../../assets"
	testutils.NeedCouchdb(t)
	setup := testutils.NewSetup(t, t.Name())
	setup.SetupSwiftTest()

	require.NoError(t, dynamic.InitDynamicAssetFS(config.FsURL().String()), "Could not init dynamic FS")
	tempdir := t.TempDir()

	cfg := config.GetConfig()
	cfg.Fs.URL = &url.URL{
		Scheme: "file",
		Host:   "localhost",
		Path:   tempdir,
	}
	was := cfg.Subdomains
	cfg.Subdomains = config.NestedSubdomains
	defer func() { cfg.Subdomains = was }()

	pass := "aephe2Ei"
	testInstance := setup.GetTestInstance(&lifecycle.Options{Domain: domain})
	params := lifecycle.PassParameters{
		Key:        "fake-encrypt-key",
		Iterations: 0,
	}
	_ = lifecycle.ForceUpdatePassphrase(testInstance, []byte(pass), params)
	testInstance.RegisterToken = nil
	testInstance.OnboardingFinished = true
	_ = instance.Update(testInstance)

	slug, err := setup.InstallMiniApp()
	require.NoError(t, err, "Could not install mini app")

	konnectorSlug, err := setup.InstallMiniKonnector()
	require.NoError(t, err, "Could not install mini konnector")

	clientSideSlug, err := setup.InstallMiniClientSideKonnector()
	require.NoError(t, err, "Could not install miniClientSideKonnector konnector")

	ts := setup.GetTestServer("/apps", webApps.WebappsRoutes, func(r *echo.Echo) *echo.Echo {
		r.POST("/login", func(c echo.Context) error {
			sess, _ := session.New(testInstance, session.LongRun, "")
			cookie, _ := sess.ToCookie()
			c.SetCookie(cookie)
			return c.HTML(http.StatusOK, "OK")
		})
		r.POST("/auth/session_code", auth.CreateSessionCode)
		router, err := web.CreateSubdomainProxy(r, &stack.Services{}, webApps.Serve)
		require.NoError(t, err, "Cant start subdoman proxy")
		return router
	})
	t.Cleanup(ts.Close)

	// Login
	cozysessID := testutils.CreateTestClient(t, ts.URL).POST("/login").
		WithHost(testInstance.Domain).
		Expect().Status(200).
		Cookie("cozysessid").Value().Raw()

	_, token := setup.GetTestClient(consts.Apps + " " + consts.Konnectors)

	t.Run("Serve", func(t *testing.T) {
		e := testutils.CreateTestClient(t, ts.URL)
		eAnon := testutils.CreateTestClient(t, ts.URL)

		assertNotPublic(e, slug, testInstance.Domain, "/foo", 302, "https://cozywithapps.example.net/auth/login?redirect=https%3A%2F%2Fmini.cozywithapps.example.net%2Ffoo")
		assertNotPublic(e, slug, testInstance.Domain, "/foo/hello.tml", 401, "")

		e = e.Builder(func(r *httpexpect.Request) {
			r.WithCookie("cozysessid", cozysessID)
		})

		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>`)
		assertAuthGet(e, slug, testInstance.Domain, "/foo/hello.html", "text/html", "utf-8", "world {{.Token}}")
		assertAuthGet(e, slug, testInstance.Domain, "/public", "text/html", "utf-8", "this is a file in public/")
		assertAuthGet(e, slug, testInstance.Domain, "/public/index.html", "text/html", "utf-8", "this is a file in public/")
		assertAnonGet(eAnon, slug, testInstance.Domain, "/public", "text/html", "utf-8", "this is a file in public/")
		assertAnonGet(eAnon, slug, testInstance.Domain, "/public/index.html", "text/html", "utf-8", "this is a file in public/")
		assertBodyContainsUnescaped(
			t,
			e.GET("/public").
				WithHost(slug+"."+testInstance.Domain).
				Expect().Status(200).
				Body().Raw(),
			`"isLoggedIn":true`,
		)
		assertBodyContainsUnescaped(
			t,
			eAnon.GET("/public").
				WithHost(slug+"."+testInstance.Domain).
				Expect().Status(200).
				Body().Raw(),
			`"isLoggedIn":false`,
		)
		assertNotFound(e, slug, testInstance.Domain, "/404")
		assertNotFound(e, slug, testInstance.Domain, "/")
		assertNotFound(e, slug, testInstance.Domain, "/index.html")
		assertNotFound(e, slug, testInstance.Domain, "/public/hello.html")
		assertInternalServerError(e, slug, testInstance.Domain, "/invalid")
	})

	t.Run("OpenGraph", func(t *testing.T) {
		eAnon := testutils.CreateTestClient(t, ts.URL)

		file, err := vfs.Create(testInstance.VFS(), "/opengraph-report.pdf")
		require.NoError(t, err)
		require.NoError(t, file.Close())
		doc, err := testInstance.VFS().FileByPath("/opengraph-report.pdf")
		require.NoError(t, err)

		createShareCode := func(subject string, password interface{}) string {
			code, err := testInstance.MakeJWT(consts.ShareAudience, subject, consts.Files, "", time.Now())
			require.NoError(t, err)
			rules := permission.Set{
				permission.Rule{
					Type:   consts.Files,
					Verbs:  permission.Verbs(permission.GET),
					Values: []string{doc.ID()},
				},
			}
			_, err = permission.CreateShareSet(
				testInstance,
				&permission.Permission{Type: permission.TypeWebapp, Permissions: rules},
				consts.Apps+"/"+slug, map[string]string{subject: code}, nil,
				permission.Permission{Permissions: rules, Password: password}, nil, false,
			)
			require.NoError(t, err)
			return code
		}

		testInstance.FeatureFlags = map[string]interface{}{publiclink.FlagEnabled: true}
		require.NoError(t, instance.Update(testInstance))
		require.NoError(t, couchdb.CreateDB(testInstance, consts.PublicLinkEvents))
		code := createShareCode("public", nil)
		t.Run("VisitorIPThroughProxy", func(t *testing.T) {
			type observedRequest struct{ clientIP, remoteAddr string }
			observed := make(chan observedRequest, 1)
			stackServer := setup.GetTestServer("/apps", webApps.WebappsRoutes, func(r *echo.Echo) *echo.Echo {
				router, err := web.CreateSubdomainProxy(r, &stack.Services{}, func(c echo.Context) error {
					observed <- observedRequest{utils.ClientIP(c.Request()), c.Request().RemoteAddr}
					return webApps.Serve(c)
				})
				require.NoError(t, err)
				return router
			})
			defer stackServer.Close()
			target, err := url.Parse(stackServer.URL)
			require.NoError(t, err)
			transport := &http.Transport{DialContext: (&net.Dialer{
				LocalAddr: &net.TCPAddr{IP: net.ParseIP("127.0.0.2")},
			}).DialContext}
			defer transport.CloseIdleConnections()
			proxy := httptest.NewServer(&httputil.ReverseProxy{
				Transport: transport,
				Rewrite: func(r *httputil.ProxyRequest) {
					r.SetURL(target)
					r.Out.Host = r.In.Host
					r.SetXForwarded()
				},
			})
			defer proxy.Close()

			response := testutils.CreateTestClient(t, proxy.URL).GET("/public").
				WithHeader("X-Forwarded-For", "203.0.113.10").
				WithQuery("sharecode", code).
				WithHost(slug + "." + testInstance.Domain).
				Expect().Status(http.StatusOK)
			response.Body().Contains(`<meta property="og:title" content="opengraph-report.pdf" />`)
			seen := <-observed
			assert.Equal(t, "127.0.0.1", seen.clientIP)
			proxyIP, _, err := net.SplitHostPort(seen.remoteAddr)
			require.NoError(t, err)
			assert.Equal(t, "127.0.0.2", proxyIP)
		})

		t.Run("EventCollection", func(t *testing.T) {
			code := createShareCode("event", nil)
			perm, err := permission.GetForShareCode(testInstance, code)
			require.NoError(t, err)
			readEvents := func() []publiclink.Event {
				var events []publiclink.Event
				require.NoError(t, couchdb.FindDocsUnoptimized(testInstance, consts.PublicLinkEvents, &couchdb.FindRequest{Selector: mango.Equal("link_id", perm.ID())}, &events))
				return events
			}
			for n := 0; n < 2; n++ {
				eAnon.GET("/public").WithQuery("sharecode", code).WithHost(slug+"."+testInstance.Domain).
					WithHeader("X-Forwarded-For", "2001:db8::123, 192.0.2.1").WithHeader("User-Agent", "preview-bot").Expect().Status(200)
			}
			require.Eventually(t, func() bool { return len(readEvents()) == 2 }, 5*time.Second, 10*time.Millisecond)
			for _, event := range readEvents() {
				require.Equal(t, publiclink.Opened, event.Action)
				require.Equal(t, "2001:db8::123", event.IP)
				require.Empty(t, event.Identity)
				require.WithinDuration(t, time.Now(), event.OccurredAt, 5*time.Second)
			}
			eAnon.HEAD("/public").WithQuery("sharecode", code).WithHost(slug + "." + testInstance.Domain).Expect().Status(200)
			eAnon.GET("/public/index.html").WithQuery("sharecode", code).WithHost(slug + "." + testInstance.Domain).Expect().Status(200)
			require.Eventually(t, func() bool { return len(readEvents()) == 3 }, 5*time.Second, 10*time.Millisecond)
			eAnon.GET("/public/hello.html").WithQuery("sharecode", code).WithHost(slug + "." + testInstance.Domain).Expect().Status(404)
			require.Len(t, readEvents(), 3)
			eAnon.GET("/foo/hello.html").WithQuery("sharecode", code).WithCookie("cozysessid", cozysessID).WithHost(slug + "." + testInstance.Domain).Expect().Status(200)
			require.Len(t, readEvents(), 3, "serving an asset is not an opening")
			eAnon.GET("/public").WithQuery("sharecode", code).WithCookie("cozysessid", cozysessID).WithHost(slug + "." + testInstance.Domain).Expect().Status(200)
			require.Eventually(t, func() bool { return len(readEvents()) == 4 }, 5*time.Second, 10*time.Millisecond)
			var identified int
			for _, event := range readEvents() {
				if event.Identity == testInstance.Domain {
					identified++
				}
			}
			require.Equal(t, 1, identified, "only an authenticated Cozy session establishes identity")
			eAnon.GET("/public").WithQuery("sharecode", code).WithCookie("cozysessid", "invalid").WithHost(slug + "." + testInstance.Domain).Expect().Status(200)
			require.Eventually(t, func() bool { return len(readEvents()) == 5 }, 5*time.Second, 10*time.Millisecond)
			identified = 0
			for _, event := range readEvents() {
				if event.Identity != "" {
					identified++
				}
			}
			require.Equal(t, 1, identified, "an invalid cookie does not establish identity")

			protectedCode := createShareCode("protected-event", "password")
			protected, err := permission.GetForShareCode(testInstance, protectedCode)
			require.NoError(t, err)
			eAnon.GET("/public").WithQuery("sharecode", protectedCode).WithHost(slug + "." + testInstance.Domain).Expect().Status(401)
			var protectedEvents []publiclink.Event
			require.NoError(t, couchdb.FindDocsUnoptimized(testInstance, consts.PublicLinkEvents, &couchdb.FindRequest{Selector: mango.Equal("link_id", protected.ID())}, &protectedEvents))
			require.Empty(t, protectedEvents)
			cookieName := "pass" + protected.ID()
			cookie, err := crypto.EncodeAuthMessage(crypto.MACConfig{Name: cookieName, MaxLen: 256}, testInstance.SessionSecret(), []byte(protected.ID()), nil)
			require.NoError(t, err)
			eAnon.GET("/public").WithQuery("sharecode", protectedCode).WithCookie(cookieName, string(cookie)).WithHost(slug + "." + testInstance.Domain).Expect().Status(200)
			require.Eventually(t, func() bool {
				require.NoError(t, couchdb.FindDocsUnoptimized(testInstance, consts.PublicLinkEvents, &couchdb.FindRequest{Selector: mango.Equal("link_id", protected.ID())}, &protectedEvents))
				return len(protectedEvents) == 1
			}, 5*time.Second, 10*time.Millisecond)
		})

		body := eAnon.GET("/public").
			WithQuery("sharecode", code).
			WithHost(slug + "." + testInstance.Domain).
			Expect().Status(200).
			Body()
		body.Contains(`<meta property="og:title" content="opengraph-report.pdf" />`)
		body.Contains(`<meta property="og:description" content="` + testInstance.Translate("Share by link OpenGraph Description") + `" />`)
		body.Contains(`<meta property="og:image" content="https://cozywithapps.example.net/assets/images/opengraph/pdf.`)

		eAnon.GET("/public").
			WithQuery("sharecode", createShareCode("protected", "the_password!")).
			WithHost(slug + "." + testInstance.Domain).
			Expect().Status(401)

		eAnon.GET("/public").
			WithHost(slug + "." + testInstance.Domain).
			Expect().Status(200).
			Body().NotContains("og:title")
	})

	t.Run("ServeWithClientsLimitExceeded", func(t *testing.T) {
		e := testutils.CreateTestClient(t, ts.URL)

		// Create the OAuth client for the flagship app
		flagship := oauth.Client{
			RedirectURIs: []string{"cozy://flagship"},
			ClientName:   "flagship-app",
			ClientKind:   "mobile",
			SoftwareID:   "github.com/cozy/cozy-stack/testing/flagship",
			Flagship:     true,
		}
		require.Nil(t, flagship.Create(testInstance, oauth.NotPending))

		testutils.WithFlag(t, testInstance, "cozy.oauthclients.max", float64(0))

		e = e.Builder(func(r *httpexpect.Request) {
			r.WithCookie("cozysessid", cozysessID)
		})

		assertAuthGet(e, slug, testInstance.Domain, "/public", "text/html", "utf-8", "this is a file in public/")
		assertAnonGet(e, slug, testInstance.Domain, "/public", "text/html", "utf-8", "this is a file in public/")

		redirect := testInstance.SubDomain(slug)
		redirect.Path = "/foo"
		location := testInstance.PageURL("/settings/clients/limit-exceeded", url.Values{"redirect": {redirect.String()}})
		assertRedirect(e, slug, testInstance.Domain, "/foo", 303, location)

		assertAuthGet(e, slug, testInstance.Domain, "/foo/hello.html", "text/html", "utf-8", "world {{.Token}}")

		testInstance.FeatureFlags = map[string]interface{}{}
		require.NoError(t, instance.Update(testInstance))
	})

	t.Run("CozyBar", func(t *testing.T) {
		e := testutils.CreateTestClient(t, ts.URL)

		e.GET("/bar/").
			WithHost(slug+"."+testInstance.Domain).
			WithCookie("cozysessid", cozysessID).
			WithRedirectPolicy(httpexpect.DontFollowRedirects).
			Expect().Status(200).
			HasContentType("text/html", "utf-8").
			Body().
			Contains(`<link rel="stylesheet" type="text/css" href="//cozywithapps.example.net/assets/css/cozy-bar`).
			Contains(`<script src="//cozywithapps.example.net/assets/js/cozy-bar`)
	})

	t.Run("Warnings", func(t *testing.T) {
		e := testutils.CreateTestClient(t, ts.URL)

		// Moved instance warning

		testInstance.Moved = true
		require.NoError(t, instance.Update(testInstance))

		e.GET("/foo/").
			WithHost(slug+"."+testInstance.Domain).
			WithCookie("cozysessid", cozysessID).
			WithRedirectPolicy(httpexpect.DontFollowRedirects).
			Expect().Status(200).
			HasContentType("text/html", "utf-8").
			Body().
			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"`)

		testInstance.Moved = false
		require.NoError(t, instance.Update(testInstance))

		// TOS not signed warning

		testutils.WithManager(t, testInstance, testutils.ManagerConfig{URL: "http://manager.example.org", WithPremiumLinks: true})

		tosSigned := testInstance.TOSSigned
		tosLatest := testInstance.TOSLatest
		tomorrow := time.Now().Add(24 * time.Hour)
		testInstance.TOSSigned = "1.0.0-20170901"
		testInstance.TOSLatest = "2.0.0-" + tomorrow.Format("20060102")
		require.NoError(t, instance.Update(testInstance))

		notSigned, deadline := testInstance.CheckTOSNotSignedAndDeadline()
		require.True(t, notSigned)
		require.Equal(t, deadline, instance.TOSWarning)

		tosLink, err := testInstance.ManagerURL(instance.ManagerTOSURL)
		require.NoError(t, err)
		require.NotEmpty(t, tosLink)

		e.GET("/foo/").
			WithHost(slug+"."+testInstance.Domain).
			WithCookie("cozysessid", cozysessID).
			WithRedirectPolicy(httpexpect.DontFollowRedirects).
			Expect().Status(200).
			HasContentType("text/html", "utf-8").
			Body().
			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 + `"`)

		testInstance.TOSSigned = tosSigned
		testInstance.TOSLatest = tosLatest
		require.NoError(t, instance.Update(testInstance))
	})

	t.Run("ServeWithAnIntents", func(t *testing.T) {
		e := testutils.CreateTestClient(t, ts.URL)

		intent := &intent.Intent{
			Action: "PICK",
			Type:   "io.cozy.foos",
			Client: "io.cozy.apps/test-app",
		}
		err := intent.Save(testInstance)
		require.NoError(t, err)
		err = intent.FillServices(testInstance)
		require.NoError(t, err)
		require.Len(t, intent.Services, 1)
		err = intent.Save(testInstance)
		require.NoError(t, err)

		u, err := url.Parse(intent.Services[0].Href)
		require.NoError(t, err)

		resp := e.GET(u.Path).
			WithHost(slug+"."+testInstance.Domain).
			WithQueryString(u.RawQuery).
			WithCookie("cozysessid", cozysessID).
			Expect().Status(200)

		csp := resp.Header(echo.HeaderContentSecurityPolicy)
		csp.Contains("script-src")
		csp.Contains("'wasm-unsafe-eval'")
		csp.Contains("connect-src blob:")
		csp.Contains("worker-src 'self' blob:;")
		csp.Contains("frame-ancestors 'self' https://test-app.cozywithapps.example.net;")

		body := resp.Body()
		body.Contains(`<script type="application/json" id="cozy-intent">`)
		body.Contains(intent.ID())
		body.Contains("PICK")
		body.Contains("io.cozy.foos")
		body.Contains("https://test-app.cozywithapps.example.net")
	})

	t.Run("ServeWithoutIntentDoesNotInline", func(t *testing.T) {
		e := testutils.CreateTestClient(t, ts.URL)

		body := e.GET("/foo").
			WithHost(slug+"."+testInstance.Domain).
			WithCookie("cozysessid", cozysessID).
			Expect().Status(200).
			Body()

		body.NotContains(`id="cozy-intent"`)
	})

	t.Run("ServeWithAnIntentsClientURL", func(t *testing.T) {
		e := testutils.CreateTestClient(t, ts.URL)

		clientURLApp := &couchdb.JSONDoc{
			Type: consts.Apps,
			M: map[string]interface{}{
				"_id":             consts.Apps + "/clienturl-app",
				"name":            "ClientURLApp",
				"slug":            "clienturl-app",
				"source":          "git://github.com/cozy/clienturl.git",
				"state":           apps.Ready,
				"client_url_flag": "clienturl_csp_flag",
				"routes": apps.Routes{
					"/foo": apps.Route{
						Folder: "/",
						Index:  "index.html",
						Public: false,
					},
				},
				"permissions": permission.Set{},
				"version":     "1.0.0",
			},
		}
		require.NoError(t, couchdb.CreateNamedDoc(testInstance, clientURLApp))
		t.Cleanup(func() { _ = couchdb.DeleteDoc(testInstance, clientURLApp) })

		testutils.WithFlag(t, testInstance, "clienturl_csp_flag", "https://external.example.com")

		intent := &intent.Intent{
			Action: "PICK",
			Type:   "io.cozy.foos",
			Client: "io.cozy.apps/clienturl-app",
		}
		err := intent.Save(testInstance)
		require.NoError(t, err)
		err = intent.FillServices(testInstance)
		require.NoError(t, err)
		require.Len(t, intent.Services, 1)
		err = intent.Save(testInstance)
		require.NoError(t, err)

		u, err := url.Parse(intent.Services[0].Href)
		require.NoError(t, err)

		csp := e.GET(u.Path).
			WithHost(slug+"."+testInstance.Domain).
			WithQueryString(u.RawQuery).
			WithCookie("cozysessid", cozysessID).
			Expect().Status(200).
			Header(echo.HeaderContentSecurityPolicy)
		csp.Contains("frame-ancestors 'self' https://external.example.com https://clienturl-app.cozywithapps.example.net;")
	})

	t.Run("ServeDataProxyWithIntentAllowsContainerAppsFrame", func(t *testing.T) {
		e := testutils.CreateTestClient(t, ts.URL)

		require.NoError(t, setup.InstallMiniDataProxy(t))
		containerApp := &couchdb.JSONDoc{
			Type: consts.Apps,
			M: map[string]interface{}{
				"_id":             consts.Apps + "/container-app",
				"name":            "ContainerApp",
				"slug":            "container-app",
				"source":          "git://github.com/cozy/containerapp.git",
				"state":           apps.Ready,
				"client_url_flag": "container_csp_flag",
				"routes":          apps.Routes{},
				"permissions":     permission.Set{},
				"version":         "1.0.0",
			},
		}
		require.NoError(t, couchdb.CreateNamedDoc(testInstance, containerApp))
		t.Cleanup(func() { _ = couchdb.DeleteDoc(testInstance, containerApp) })

		testutils.WithFlag(t, testInstance, "container_csp_flag", "https://external.example.com")

		intent := &intent.Intent{
			Action:   "PICK",
			Type:     "io.cozy.foos",
			Client:   "io.cozy.apps/container-app",
			Services: []intent.Service{{Slug: consts.DataProxySlug, Href: "/"}},
		}
		require.NoError(t, intent.Save(testInstance))

		csp := e.GET("/").
			WithHost(consts.DataProxySlug + "." + testInstance.Domain).
			WithQueryString("intent=" + intent.ID()).
			Expect().Status(200).
			Header(echo.HeaderContentSecurityPolicy)

		csp.Contains("frame-ancestors 'self'")
		// Cozy root (login page rule)
		csp.Contains("https://cozywithapps.example.net/")
		// All container apps and their external clients
		csp.Contains("https://external.example.com")
		csp.Contains("https://container-app.cozywithapps.example.net")
		// Apps without a client_url_flag are not added
		csp.NotContains("https://mini.cozywithapps.example.net")
	})

	t.Run("ServeAddsNoExternalIntentServiceWithoutServiceURLFlag", func(t *testing.T) {
		e := testutils.CreateTestClient(t, ts.URL)

		// The flag has a valid value, but no app declares it as its
		// service_url_flag: nothing is added to frame-src
		testutils.WithFlag(t, testInstance, "extservice_url_flag", "https://calendar.external.example.com/app/")
		csp := e.GET("/foo/").
			WithHost(slug+"."+testInstance.Domain).
			WithCookie("cozysessid", cozysessID).
			Expect().Status(200).
			Header(echo.HeaderContentSecurityPolicy)
		csp.NotContains("calendar.external.example.com")
	})

	t.Run("ServeAddsExternalIntentServicesToFrameSrc", func(t *testing.T) {
		e := testutils.CreateTestClient(t, ts.URL)

		externalServiceApp := &couchdb.JSONDoc{
			Type: consts.Apps,
			M: map[string]interface{}{
				"_id":              consts.Apps + "/extservice-app",
				"name":             "ExtServiceApp",
				"slug":             "extservice-app",
				"source":           "git://github.com/cozy/extservice.git",
				"state":            apps.Ready,
				"service_url_flag": "extservice_url_flag",
				"routes":           apps.Routes{},
				"permissions":      permission.Set{},
				"version":          "1.0.0",
			},
		}
		require.NoError(t, couchdb.CreateNamedDoc(testInstance, externalServiceApp))
		t.Cleanup(func() { _ = couchdb.DeleteDoc(testInstance, externalServiceApp) })

		// Flag absent: nothing is added
		csp := e.GET("/foo/").
			WithHost(slug+"."+testInstance.Domain).
			WithCookie("cozysessid", cozysessID).
			Expect().Status(200).
			Header(echo.HeaderContentSecurityPolicy)
		csp.NotContains("calendar.external.example.com")
		csp.NotContains("https://extservice-app.cozywithapps.example.net")

		// Flag set: its origin (not its path) is added to frame-src
		testutils.WithFlag(t, testInstance, "extservice_url_flag", "https://calendar.external.example.com/app/")
		csp = e.GET("/foo/").
			WithHost(slug+"."+testInstance.Domain).
			WithCookie("cozysessid", cozysessID).
			Expect().Status(200).
			Header(echo.HeaderContentSecurityPolicy)
		csp.Match(`frame-src [^;]*https://calendar\.external\.example\.com[ ;]`)
		csp.NotContains("https://calendar.external.example.com/app/")

		// Flag with a CSP separator in its host: nothing is added
		testutils.WithFlag(t, testInstance, "extservice_url_flag", "https://x.example;sandbox")
		csp = e.GET("/foo/").
			WithHost(slug+"."+testInstance.Domain).
			WithCookie("cozysessid", cozysessID).
			Expect().Status(200).
			Header(echo.HeaderContentSecurityPolicy)
		csp.NotContains("x.example")
		csp.NotContains("sandbox")
	})

	t.Run("NoScriptTagBreakoutIntentData", func(t *testing.T) {
		e := testutils.CreateTestClient(t, ts.URL)

		intent := &intent.Intent{
			Action: "PICK",
			Type:   "io.cozy.foos",
			Client: "io.cozy.apps/" + slug,
		}
		err := intent.Save(testInstance)
		require.NoError(t, err)
		err = intent.FillServices(testInstance)
		require.NoError(t, err)
		require.Len(t, intent.Services, 1)
		intent.Action = "PICK</script><script>alert('XSS')</script>"
		err = intent.Save(testInstance)
		require.NoError(t, err)

		u, err := url.Parse(intent.Services[0].Href)
		require.NoError(t, err)

		body := e.GET(u.Path).
			WithHost(slug+"."+testInstance.Domain).
			WithQueryString(u.RawQuery).
			WithCookie("cozysessid", cozysessID).
			Expect().Status(200).
			Body().Raw()

		// IntentData returns template.HTML (raw, unescaped by html/template).
		// json.Marshal escapes < to \u003c to prevent </script> breakout
		// inside the JSON embedded in a <script> block.
		assert.Contains(t, body, `PICK\u003c/script\u003e`)
	})

	t.Run("FaviconWithContext", func(t *testing.T) {
		e := testutils.CreateTestClient(t, ts.URL)

		context := "foo"

		asset, ok := assets.Get("/favicon.ico", context)
		if ok {
			_ = assets.Remove(asset.Name, asset.Context)
		}
		// Create and insert an asset in foo context
		tmpdir := t.TempDir()
		_, err := os.OpenFile(filepath.Join(tmpdir, "custom_favicon.png"), os.O_RDWR|os.O_CREATE, 0600)
		assert.NoError(t, err)

		assetsOptions := []model.AssetOption{{
			URL:     fmt.Sprintf("file://%s", filepath.Join(tmpdir, "custom_favicon.png")),
			Name:    "/favicon.ico",
			Context: context,
		}}
		err = dynamic.RegisterCustomExternals(assetsOptions, 1)
		assert.NoError(t, err)

		// Test the theme
		assert.NoError(t, lifecycle.Patch(testInstance, &lifecycle.Options{
			ContextName: context,
		}))
		assert.NoError(t, err)

		e.GET("/foo").
			WithHost(slug+"."+testInstance.Domain).
			WithCookie("cozysessid", cozysessID).
			WithRedirectPolicy(httpexpect.DontFollowRedirects).
			Expect().Status(200).
			Body().
			Contains(`this is index.html. <a lang="en" href="https://cozywithapps.example.net/status/">Status</a>`).
			Contains(fmt.Sprintf("/assets/ext/%s/favicon.ico", context)).
			NotContains("/assets/favicon.ico")
	})

	t.Run("SessionCode", func(t *testing.T) {
		e := testutils.CreateTestClient(t, ts.URL)

		// Create the OAuth client for the flagship app
		flagship := oauth.Client{
			RedirectURIs: []string{"cozy://flagship"},
			ClientName:   "flagship-app",
			SoftwareID:   "github.com/cozy/cozy-stack/testing/flagship",
			Flagship:     true,
		}
		assert.Nil(t, flagship.Create(testInstance, oauth.NotPending))

		// Create a maximal permission for it
		token, err := testInstance.MakeJWT(consts.AccessTokenAudience,
			flagship.ClientID, "*", "", time.Now())
		assert.NoError(t, err)

		createSessionCode := func() string {
			return e.POST("/auth/session_code").
				WithHost(testInstance.Domain).
				WithHeader("Authorization", "Bearer "+token).
				Expect().Status(201).
				JSON().Object().
				Value("session_code").String().NotEmpty().Raw()
		}

		// Load a non-public page
		code := createSessionCode()
		e.GET("/foo/").
			WithQuery("session_code", code).
			WithHost(slug+"."+testInstance.Domain).
			WithCookie("cozysessid", cozysessID).
			Expect().Status(200).
			Body().Contains("this is index.html")

		// Try again and check that the session code cannot be reused
		e.GET("/foo/").
			WithQuery("session_code", code).
			WithHost(slug + "." + testInstance.Domain).
			WithRedirectPolicy(httpexpect.DontFollowRedirects).
			Expect().Status(302).
			Header("Location").Contains("/auth/login")

		redirectCode := createSessionCode()
		redirect := testutils.CreateTestClient(t, ts.URL).GET("/foo/").
			WithQuery("intent", "intent-session-code-test").
			WithQuery("session_code", redirectCode).
			WithQuery("extra", "drop-me").
			WithHost(slug + "." + testInstance.Domain).
			WithRedirectPolicy(httpexpect.DontFollowRedirects).
			Expect().Status(http.StatusSeeOther)

		redirect.Cookie("cozysessid").Value().NotEmpty()
		redirect.Header("Location").Contains("intent=intent-session-code-test")
		redirect.Header("Location").NotContains("session_code")
		redirect.Header("Location").NotContains("extra")
	})

	t.Run("ServeAppsWithJWTNotLogged", func(t *testing.T) {
		e := testutils.CreateTestClient(t, ts.URL)

		config.GetConfig().Subdomains = config.FlatSubdomains
		appHost := "cozywithapps-mini.example.net"

		rawURL := e.GET("/foo").
			WithQuery("jwt", "abc").
			WithHost(appHost).
			WithRedirectPolicy(httpexpect.DontFollowRedirects).
			WithCookie("cozysessid", cozysessID).
			Expect().Status(302).
			Header("Location").Raw()

		location, err := url.Parse(rawURL)
		require.NoError(t, err)

		assert.Equal(t, "/auth/login", location.Path)
		assert.Equal(t, testInstance.Domain, location.Host)
		assert.NotEmpty(t, location.Query().Get("redirect"))
		assert.Equal(t, "abc", location.Query().Get("jwt"))
	})

	t.Run("OauthAppCantInstallApp", func(t *testing.T) {
		e := testutils.CreateTestClient(t, ts.URL)

		e.POST("/apps/mini-bis").
			WithHost(testInstance.Domain).
			WithQuery("Source", "git://github.com/nono/cozy-mini.git").
			WithHeader("Authorization", "Bearer "+token).
			WithCookie("cozysessid", cozysessID).
			Expect().Status(403)
	})

	t.Run("OauthAppCantUpdateApp", func(t *testing.T) {
		e := testutils.CreateTestClient(t, ts.URL)

		e.PUT("/apps/mini").
			WithHost(testInstance.Domain).
			WithHeader("Authorization", "Bearer "+token).
			WithCookie("cozysessid", cozysessID).
			Expect().Status(403)
	})

	t.Run("ListApps", func(t *testing.T) {
		e := testutils.CreateTestClient(t, ts.URL)

		obj := e.GET("/apps/").
			WithHost(testInstance.Domain).
			WithHeader("Authorization", "Bearer "+token).
			WithCookie("cozysessid", cozysessID).
			Expect().Status(200).
			JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
			Object()

		data := obj.Value("data").Array()
		data.Length().IsEqual(1)

		elem := data.Value(0).Object()
		elem.Value("id").String().NotEmpty()
		elem.HasValue("type", "io.cozy.apps")

		attrs := elem.Value("attributes").Object()
		attrs.HasValue("name", "Mini")
		attrs.HasValue("slug", "mini")

		links := elem.Value("links").Object()
		links.HasValue("self", "/apps/mini")
		links.HasValue("related", "https://cozywithapps-mini.example.net/")
		links.HasValue("icon", "/apps/mini/icon/1.0.0")
	})

	t.Run("IconForApp", func(t *testing.T) {
		e := testutils.CreateTestClient(t, ts.URL)

		e.GET("/apps/mini/icon").
			WithHost(testInstance.Domain).
			WithHeader("Authorization", "Bearer "+token).
			Expect().Status(200).
			Body().IsEqual("<svg>...</svg>")
	})

	t.Run("DownloadApp", func(t *testing.T) {
		e := testutils.CreateTestClient(t, ts.URL)

		res := e.GET("/apps/mini/download").
			WithHost(testInstance.Domain).
			WithHeader("Authorization", "Bearer "+token).
			Expect().Status(200).
			Raw()

		mimeType, reader := filetype.FromReader(res.Body)
		require.Equal(t, "application/gzip", mimeType)
		gr, err := gzip.NewReader(reader)
		require.NoError(t, err)
		tr := tar.NewReader(gr)
		indexFound := false
		for {
			header, err := tr.Next()
			if errors.Is(err, io.EOF) {
				break
			}
			require.NoError(t, err)
			if header.Name == "/index.html" {
				indexFound = true
			}
		}
		require.True(t, indexFound)
	})

	t.Run("DownloadKonnectorVersion", func(t *testing.T) {
		e := testutils.CreateTestClient(t, ts.URL)

		res := e.GET("/konnectors/mini/download/1.0.0").
			WithHost(testInstance.Domain).
			WithHeader("Authorization", "Bearer "+token).
			Expect().Status(200).
			Raw()

		mimeType, reader := filetype.FromReader(res.Body)
		require.Equal(t, "application/gzip", mimeType)
		gr, err := gzip.NewReader(reader)
		require.NoError(t, err)
		tr := tar.NewReader(gr)
		iconFound := false
		for {
			header, err := tr.Next()
			if errors.Is(err, io.EOF) {
				break
			}
			require.NoError(t, err)
			if header.Name == "/icon.svg" {
				iconFound = true
			}
		}
		require.True(t, iconFound)
	})

	t.Run("OpenWebapp", func(t *testing.T) {
		e := testutils.CreateTestClient(t, ts.URL)

		// Expected flags since they can be modified by other tests
		flags, err := feature.GetFlags(testInstance)
		require.NoError(t, err)
		flagsStr, err := json.Marshal(flags)
		require.NoError(t, err)

		// Create the OAuth client for the flagship app
		flagship := oauth.Client{
			RedirectURIs: []string{"cozy://flagship"},
			ClientName:   "flagship-app",
			SoftwareID:   "github.com/cozy/cozy-stack/testing/flagship",
			Flagship:     true,
		}
		require.Nil(t, flagship.Create(testInstance, oauth.NotPending))

		// Create a maximal permission for it
		token, err := testInstance.MakeJWT(consts.AccessTokenAudience,
			flagship.ClientID, "*", "", time.Now())
		require.NoError(t, err)

		obj := e.GET("/apps/mini/open").
			WithHost(testInstance.Domain).
			WithHeader("Authorization", "Bearer "+token).
			Expect().Status(200).
			JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
			Object()

		data := obj.Value("data").Object()
		data.Value("id").String().NotEmpty()
		data.HasValue("type", "io.cozy.apps.open")

		attrs := data.Value("attributes").Object()
		attrs.HasValue("AppName", "Mini")
		attrs.HasValue("AppSlug", "mini")
		attrs.HasValue("IconPath", "icon.svg")
		attrs.HasValue("Tracking", "false")
		attrs.HasValue("SubDomain", "flat")
		attrs.Value("Cookie").String().Contains("HttpOnly")
		attrs.Value("Token").String().NotEmpty()
		attrs.HasValue("Flags", string(flagsStr))
		attrs.ContainsKey("Warnings")

		links := data.Value("links").Object()
		links.HasValue("self", "/apps/mini/open")
	})

	t.Run("UninstallAppWithLinkedClient", func(t *testing.T) {
		e := testutils.CreateTestClient(t, ts.URL)

		// Install drive app
		installer, err := apps.NewInstaller(testInstance, apps.Copier(consts.WebappType, testInstance),
			&apps.InstallerOptions{
				Operation:  apps.Install,
				Type:       consts.WebappType,
				Slug:       "drive",
				SourceURL:  "registry://drive",
				Registries: testInstance.Registries(),
			},
		)
		require.NoError(t, err)

		_, err = installer.RunSync()
		require.NoError(t, err)

		// Link an OAuthClient to drive
		oauthClient := &oauth.Client{
			ClientName:   "test-linked",
			RedirectURIs: []string{"https://foobar"},
			SoftwareID:   "registry://drive",
		}

		oauthClient.Create(testInstance)
		// Forcing the oauthClient to get a couchID for the purpose of later deletion
		oauthClient, err = oauth.FindClient(testInstance, oauthClient.ClientID)
		require.NoError(t, err)

		scope := "io.cozy.apps:ALL"
		token, err := testInstance.MakeJWT("cli", "drive", scope, "", time.Now())
		require.NoError(t, err)

		// Trying to remove this app
		e.DELETE("/apps/drive").
			WithHost(testInstance.Domain).
			WithHeader("Authorization", "Bearer "+token).
			Expect().Status(400).
			Body().Contains("linked OAuth client exists")

		// Cleaning
		uninstaller, err := apps.NewInstaller(testInstance, apps.Copier(consts.WebappType, testInstance),
			&apps.InstallerOptions{
				Operation:  apps.Delete,
				Type:       consts.WebappType,
				Slug:       "drive",
				SourceURL:  "registry://drive",
				Registries: testInstance.Registries(),
			},
		)
		assert.NoError(t, err)
		_, err = uninstaller.RunSync()
		assert.NoError(t, err)
		errc := oauthClient.Delete(testInstance)
		assert.Nil(t, errc)
	})

	t.Run("UninstallAppWithoutLinkedClient", func(t *testing.T) {
		e := testutils.CreateTestClient(t, ts.URL)

		// Install drive app
		installer, err := apps.NewInstaller(testInstance, apps.Copier(consts.WebappType, testInstance),
			&apps.InstallerOptions{
				Operation:  apps.Install,
				Type:       consts.WebappType,
				Slug:       "drive",
				SourceURL:  "registry://drive",
				Registries: testInstance.Registries(),
			},
		)
		require.NoError(t, err)

		_, err = installer.RunSync()
		require.NoError(t, err)

		// Create an OAuth client not linked to drive
		oauthClient := &oauth.Client{
			ClientName:   "test-linked",
			RedirectURIs: []string{"https://foobar"},
			SoftwareID:   "foobarclient",
		}
		oauthClient.Create(testInstance)
		// Forcing the oauthClient to get a couchID for the purpose of later deletion
		oauthClient, err = oauth.FindClient(testInstance, oauthClient.ClientID)
		require.NoError(t, err)

		scope := "io.cozy.apps:ALL"
		token, err := testInstance.MakeJWT("cli", "drive", scope, "", time.Now())
		assert.NoError(t, err)

		// Trying to remove this app
		e.DELETE("/apps/drive").
			WithHost(testInstance.Domain).
			WithHeader("Authorization", "Bearer "+token).
			Expect().Status(200)

		// Cleaning
		errc := oauthClient.Delete(testInstance)
		assert.Nil(t, errc)
	})

	t.Run("SendKonnectorLogsFromFlagshipApp", func(t *testing.T) {
		e := testutils.CreateTestClient(t, ts.URL)

		initialOutput := logrus.New().Out
		defer logrus.SetOutput(initialOutput)

		testOutput := new(bytes.Buffer)
		logrus.SetOutput(testOutput)

		// Create the OAuth client for the flagship app
		flagship := oauth.Client{
			RedirectURIs: []string{"cozy://flagship"},
			ClientName:   "flagship-app",
			SoftwareID:   "github.com/cozy/cozy-stack/testing/flagship",
			Flagship:     true,
		}
		require.Nil(t, flagship.Create(testInstance, oauth.NotPending))

		// Give it the maximal permission
		token, err := testInstance.MakeJWT(consts.AccessTokenAudience, flagship.ClientID, "*", "", time.Now())
		require.NoError(t, err)

		// Send logs for a client side konnector
		e.POST("/konnectors/"+clientSideSlug+"/logs").
			WithHost(testInstance.Domain).
			WithHeader("Authorization", "Bearer "+token).
			WithBytes([]byte(`[ { "timestamp": "2022-10-27T17:13:38.382Z", "level": "error", "msg": "This is an error message" } ]`)).
			Expect().Status(204)

		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())

		// Send logs for a konnector
		testOutput.Reset()
		e.POST("/konnectors/"+konnectorSlug+"/logs").
			WithHost(testInstance.Domain).
			WithHeader("Authorization", "Bearer "+token).
			WithBytes([]byte(`[ { "timestamp": "2022-10-27T17:13:38.382Z", "level": "error", "msg": "This is an error message" } ]`)).
			Expect().Status(204)

		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())

		// Send logs for a webapp
		testOutput.Reset()
		e.POST("/apps/"+slug+"/logs").
			WithHost(testInstance.Domain).
			WithHeader("Authorization", "Bearer "+token).
			WithBytes([]byte(`[ { "timestamp": "2022-10-27T17:13:38.382Z", "level": "error", "msg": "This is an error message" } ]`)).
			Expect().Status(204)

		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())
	})

	t.Run("SendKonnectorLogsFromKonnector", func(t *testing.T) {
		e := testutils.CreateTestClient(t, ts.URL)

		initialOutput := logrus.New().Out
		defer logrus.SetOutput(initialOutput)

		testOutput := new(bytes.Buffer)
		logrus.SetOutput(testOutput)

		token := testInstance.BuildKonnectorToken(slug)

		e.POST("/konnectors/"+slug+"/logs").
			WithHost(testInstance.Domain).
			WithHeader("Authorization", "Bearer "+token).
			WithBytes([]byte(`[ { "timestamp": "2022-10-27T17:13:38.382Z", "level": "error", "msg": "This is an error message" } ]`)).
			Expect().Status(204)

		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())

		// Sending logs for a webapp should fail
		e.POST("/apps/"+slug+"/logs").
			WithHost(testInstance.Domain).
			WithHeader("Authorization", "Bearer "+token).
			WithBytes([]byte(`[ { "timestamp": "2022-10-27T17:13:38.382Z", "level": "error", "msg": "This is an error message" } ]`)).
			Expect().Status(403)
	})

	t.Run("SendAppLogsFromWebApp", func(t *testing.T) {
		e := testutils.CreateTestClient(t, ts.URL)

		initialOutput := logrus.New().Out
		defer logrus.SetOutput(initialOutput)

		testOutput := new(bytes.Buffer)
		logrus.SetOutput(testOutput)

		token := testInstance.BuildAppToken(slug, "")

		e.POST("/apps/"+slug+"/logs").
			WithHost(testInstance.Domain).
			WithHeader("Authorization", "Bearer "+token).
			WithBytes([]byte(`[ { "timestamp": "2022-10-27T17:13:38.382Z", "level": "error", "msg": "This is an error message" } ]`)).
			Expect().Status(204)

		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())

		// Sending logs for a konnector should fail
		e.POST("/konnectors/"+slug+"/logs").
			WithHost(testInstance.Domain).
			WithHeader("Authorization", "Bearer "+token).
			WithBytes([]byte(`[ { "timestamp": "2022-10-27T17:13:38.382Z", "level": "error", "msg": "This is an error message" } ]`)).
			Expect().Status(403)
	})
}

func assertAuthGet(e *httpexpect.Expect, slug, domain, path, contentType, charset, content string) {
	e.GET(path).
		WithHost(slug+"."+domain).
		Expect().Status(200).
		HasContentType(contentType, charset).
		Body().Contains(content)
}

func assertAnonGet(e *httpexpect.Expect, slug, domain, path, contentType, charset, content string) {
	e.GET(path).
		WithHost(slug+"."+domain).
		Expect().Status(200).
		HasContentType(contentType, charset).
		Body().Contains(content)
}

func assertNotPublic(e *httpexpect.Expect, slug, domain, path string, code int, location string) {
	e.GET(path).
		WithHost(slug + "." + domain).
		WithRedirectPolicy(httpexpect.DontFollowRedirects).
		Expect().Status(code).
		Header("Location").IsEqual(location)
}

func assertNotFound(e *httpexpect.Expect, slug, domain, path string) {
	e.GET(path).
		WithHost(slug + "." + domain).
		WithRedirectPolicy(httpexpect.DontFollowRedirects).
		Expect().Status(404)
}

func assertInternalServerError(e *httpexpect.Expect, slug, domain, path string) {
	e.GET(path).
		WithHost(slug + "." + domain).
		Expect().Status(500)
}

func assertBodyContainsUnescaped(t *testing.T, body, content string) {
	t.Helper()
	assert.Contains(t, html.UnescapeString(body), content)
}

func assertRedirect(e *httpexpect.Expect, slug, domain, path string, code int, location string) {
	e.GET(path).
		WithHost(slug + "." + domain).
		WithRedirectPolicy(httpexpect.DontFollowRedirects).
		Expect().Status(code).
		Header("Location").IsEqual(location)
}