package coderd_test

import (
	"bufio"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/http/httputil"
	"net/url"
	"strings"
	"testing"
	"time"

	"github.com/google/uuid"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"golang.org/x/xerrors"

	"cdr.dev/slog/sloggers/slogtest"
	"github.com/coder/coder/agent"
	"github.com/coder/coder/coderd"
	"github.com/coder/coder/coderd/coderdtest"
	"github.com/coder/coder/coderd/httpapi"
	"github.com/coder/coder/coderd/httpmw"
	"github.com/coder/coder/coderd/rbac"
	"github.com/coder/coder/codersdk"
	"github.com/coder/coder/provisioner/echo"
	"github.com/coder/coder/provisionersdk/proto"
	"github.com/coder/coder/testutil"
)

const (
	proxyTestAgentName            = "agent-name"
	proxyTestAppNameFake          = "test-app-fake"
	proxyTestAppNameOwner         = "test-app-owner"
	proxyTestAppNameAuthenticated = "test-app-authenticated"
	proxyTestAppNamePublic        = "test-app-public"
	proxyTestAppQuery             = "query=true"
	proxyTestAppBody              = "hello world from apps test"

	proxyTestSubdomainRaw = "*.test.coder.com"
	proxyTestSubdomain    = "test.coder.com"
)

func TestGetAppHost(t *testing.T) {
	t.Parallel()

	cases := []struct {
		name        string
		accessURL   string
		appHostname string
		expected    string
	}{
		{
			name:        "OK",
			accessURL:   "https://test.coder.com",
			appHostname: "*.test.coder.com",
			expected:    "*.test.coder.com",
		},
		{
			name:        "None",
			accessURL:   "https://test.coder.com",
			appHostname: "",
			expected:    "",
		},
		{
			name:        "OKWithPort",
			accessURL:   "https://test.coder.com:8443",
			appHostname: "*.test.coder.com",
			expected:    "*.test.coder.com:8443",
		},
		{
			name:        "OKWithSuffix",
			accessURL:   "https://test.coder.com:8443",
			appHostname: "*--suffix.test.coder.com",
			expected:    "*--suffix.test.coder.com:8443",
		},
	}
	for _, c := range cases {
		c := c
		t.Run(c.name, func(t *testing.T) {
			t.Parallel()

			accessURL, err := url.Parse(c.accessURL)
			require.NoError(t, err)

			client := coderdtest.New(t, &coderdtest.Options{
				AccessURL:   accessURL,
				AppHostname: c.appHostname,
			})

			ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
			defer cancel()

			// Should not leak to unauthenticated users.
			host, err := client.GetAppHost(ctx)
			require.Error(t, err)
			require.Equal(t, "", host.Host)

			_ = coderdtest.CreateFirstUser(t, client)
			host, err = client.GetAppHost(ctx)
			require.NoError(t, err)
			require.Equal(t, c.expected, host.Host)
		})
	}
}

type setupProxyTestOpts struct {
	AppHost                              string
	DisablePathApps                      bool
	DangerousAllowPathAppSharing         bool
	DangerousAllowPathAppSiteOwnerAccess bool

	NoWorkspace bool
}

// setupProxyTest creates a workspace with an agent and some apps. It returns a
// codersdk client, the first user, the workspace, and the port number the test
// listener is running on.
func setupProxyTest(t *testing.T, opts *setupProxyTestOpts) (*codersdk.Client, codersdk.CreateFirstUserResponse, *codersdk.Workspace, uint16) {
	if opts == nil {
		opts = &setupProxyTestOpts{}
	}
	if opts.AppHost == "" {
		opts.AppHost = proxyTestSubdomainRaw
	}

	// #nosec
	ln, err := net.Listen("tcp", ":0")
	require.NoError(t, err)
	server := http.Server{
		ReadHeaderTimeout: time.Minute,
		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			_, err := r.Cookie(codersdk.SessionTokenKey)
			assert.ErrorIs(t, err, http.ErrNoCookie)
			w.Header().Set("X-Forwarded-For", r.Header.Get("X-Forwarded-For"))
			w.WriteHeader(http.StatusOK)
			_, _ = w.Write([]byte(proxyTestAppBody))
		}),
	}
	t.Cleanup(func() {
		_ = server.Close()
		_ = ln.Close()
	})
	go server.Serve(ln)
	tcpAddr, ok := ln.Addr().(*net.TCPAddr)
	require.True(t, ok)

	deploymentConfig := coderdtest.DeploymentConfig(t)
	deploymentConfig.DisablePathApps.Value = opts.DisablePathApps
	deploymentConfig.Dangerous.AllowPathAppSharing.Value = opts.DangerousAllowPathAppSharing
	deploymentConfig.Dangerous.AllowPathAppSiteOwnerAccess.Value = opts.DangerousAllowPathAppSiteOwnerAccess

	client := coderdtest.New(t, &coderdtest.Options{
		DeploymentConfig:            deploymentConfig,
		AppHostname:                 opts.AppHost,
		IncludeProvisionerDaemon:    true,
		AgentStatsRefreshInterval:   time.Millisecond * 100,
		MetricsCacheRefreshInterval: time.Millisecond * 100,
		RealIPConfig: &httpmw.RealIPConfig{
			TrustedOrigins: []*net.IPNet{{
				IP:   net.ParseIP("127.0.0.1"),
				Mask: net.CIDRMask(8, 32),
			}},
			TrustedHeaders: []string{
				"CF-Connecting-IP",
			},
		},
	})

	user := coderdtest.CreateFirstUser(t, client)

	var workspace *codersdk.Workspace
	if !opts.NoWorkspace {
		ws := createWorkspaceWithApps(t, client, user.OrganizationID, opts.AppHost, uint16(tcpAddr.Port))
		workspace = &ws
	}

	// Configure the HTTP client to not follow redirects and to route all
	// requests regardless of hostname to the coderd test server.
	client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
		return http.ErrUseLastResponse
	}
	forceURLTransport(t, client)

	return client, user, workspace, uint16(tcpAddr.Port)
}

func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.UUID, appHost string, port uint16, workspaceMutators ...func(*codersdk.CreateWorkspaceRequest)) codersdk.Workspace {
	authToken := uuid.NewString()

	appURL := fmt.Sprintf("http://127.0.0.1:%d?%s", port, proxyTestAppQuery)
	version := coderdtest.CreateTemplateVersion(t, client, orgID, &echo.Responses{
		Parse:         echo.ParseComplete,
		ProvisionPlan: echo.ProvisionComplete,
		ProvisionApply: []*proto.Provision_Response{{
			Type: &proto.Provision_Response_Complete{
				Complete: &proto.Provision_Complete{
					Resources: []*proto.Resource{{
						Name: "example",
						Type: "aws_instance",
						Agents: []*proto.Agent{{
							Id:   uuid.NewString(),
							Name: proxyTestAgentName,
							Auth: &proto.Agent_Token{
								Token: authToken,
							},
							Apps: []*proto.App{
								{
									Slug:         proxyTestAppNameFake,
									DisplayName:  proxyTestAppNameFake,
									SharingLevel: proto.AppSharingLevel_OWNER,
									// Hopefully this IP and port doesn't exist.
									Url: "http://127.1.0.1:65535",
								},
								{
									Slug:         proxyTestAppNameOwner,
									DisplayName:  proxyTestAppNameOwner,
									SharingLevel: proto.AppSharingLevel_OWNER,
									Url:          appURL,
								},
								{
									Slug:         proxyTestAppNameAuthenticated,
									DisplayName:  proxyTestAppNameAuthenticated,
									SharingLevel: proto.AppSharingLevel_AUTHENTICATED,
									Url:          appURL,
								},
								{
									Slug:         proxyTestAppNamePublic,
									DisplayName:  proxyTestAppNamePublic,
									SharingLevel: proto.AppSharingLevel_PUBLIC,
									Url:          appURL,
								},
							},
						}},
					}},
				},
			},
		}},
	})
	template := coderdtest.CreateTemplate(t, client, orgID, version.ID)
	coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
	workspace := coderdtest.CreateWorkspace(t, client, orgID, template.ID, workspaceMutators...)
	coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)

	ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
	defer cancel()

	user, err := client.User(ctx, codersdk.Me)
	require.NoError(t, err)

	agentClient := codersdk.New(client.URL)
	agentClient.SetSessionToken(authToken)
	if appHost != "" {
		metadata, err := agentClient.WorkspaceAgentMetadata(context.Background())
		require.NoError(t, err)
		proxyURL := fmt.Sprintf(
			"http://{{port}}--%s--%s--%s%s",
			proxyTestAgentName,
			workspace.Name,
			user.Username,
			strings.ReplaceAll(appHost, "*", ""),
		)
		if client.URL.Port() != "" {
			proxyURL += fmt.Sprintf(":%s", client.URL.Port())
		}
		require.Equal(t, proxyURL, metadata.VSCodePortProxyURI)
	}
	agentCloser := agent.New(agent.Options{
		Client: agentClient,
		Logger: slogtest.Make(t, nil).Named("agent"),
	})
	t.Cleanup(func() {
		_ = agentCloser.Close()
	})
	coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)

	return workspace
}

func TestWorkspaceAppsProxyPath(t *testing.T) {
	t.Parallel()
	client, firstUser, workspace, _ := setupProxyTest(t, nil)

	t.Run("Disabled", func(t *testing.T) {
		t.Parallel()

		deploymentConfig := coderdtest.DeploymentConfig(t)
		deploymentConfig.DisablePathApps.Value = true

		client := coderdtest.New(t, &coderdtest.Options{
			DeploymentConfig:            deploymentConfig,
			IncludeProvisionerDaemon:    true,
			AgentStatsRefreshInterval:   time.Millisecond * 100,
			MetricsCacheRefreshInterval: time.Millisecond * 100,
		})
		user := coderdtest.CreateFirstUser(t, client)
		workspace := createWorkspaceWithApps(t, client, user.OrganizationID, "", 0)

		ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
		defer cancel()

		resp, err := requestWithRetries(ctx, t, client, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s", workspace.Name, proxyTestAppNameOwner), nil)
		require.NoError(t, err)
		defer resp.Body.Close()
		require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
		body, err := io.ReadAll(resp.Body)
		require.NoError(t, err)
		require.Contains(t, string(body), "Path-based applications are disabled")
	})

	t.Run("LoginWithoutAuth", func(t *testing.T) {
		t.Parallel()
		client := codersdk.New(client.URL)
		client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
			return http.ErrUseLastResponse
		}

		ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
		defer cancel()

		resp, err := requestWithRetries(ctx, t, client, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s", workspace.Name, proxyTestAppNameOwner), nil)
		require.NoError(t, err)
		defer resp.Body.Close()

		require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
		loc, err := resp.Location()
		require.NoError(t, err)
		require.True(t, loc.Query().Has("message"))
		require.True(t, loc.Query().Has("redirect"))
	})

	t.Run("NoAccessShould404", func(t *testing.T) {
		t.Parallel()

		userClient := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID, rbac.RoleMember())
		userClient.HTTPClient.CheckRedirect = client.HTTPClient.CheckRedirect
		userClient.HTTPClient.Transport = client.HTTPClient.Transport

		ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
		defer cancel()

		resp, err := requestWithRetries(ctx, t, userClient, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s", workspace.Name, proxyTestAppNameOwner), nil)
		require.NoError(t, err)
		defer resp.Body.Close()
		require.Equal(t, http.StatusNotFound, resp.StatusCode)
	})

	t.Run("RedirectsWithSlash", func(t *testing.T) {
		t.Parallel()

		ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
		defer cancel()

		resp, err := requestWithRetries(ctx, t, client, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s", workspace.Name, proxyTestAppNameOwner), nil)
		require.NoError(t, err)
		defer resp.Body.Close()
		require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
	})

	t.Run("RedirectsWithQuery", func(t *testing.T) {
		t.Parallel()

		ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
		defer cancel()

		resp, err := requestWithRetries(ctx, t, client, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s/", workspace.Name, proxyTestAppNameOwner), nil)
		require.NoError(t, err)
		defer resp.Body.Close()
		require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
		loc, err := resp.Location()
		require.NoError(t, err)
		require.Equal(t, proxyTestAppQuery, loc.RawQuery)
	})

	t.Run("Proxies", func(t *testing.T) {
		t.Parallel()

		ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
		defer cancel()

		resp, err := requestWithRetries(ctx, t, client, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s/?%s", workspace.Name, proxyTestAppNameOwner, proxyTestAppQuery), nil)
		require.NoError(t, err)
		defer resp.Body.Close()
		body, err := io.ReadAll(resp.Body)
		require.NoError(t, err)
		require.Equal(t, proxyTestAppBody, string(body))
		require.Equal(t, http.StatusOK, resp.StatusCode)
	})

	t.Run("ForwardsIP", func(t *testing.T) {
		t.Parallel()

		ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
		defer cancel()

		resp, err := requestWithRetries(ctx, t, client, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s/?%s", workspace.Name, proxyTestAppNameOwner, proxyTestAppQuery), nil, func(r *http.Request) {
			r.Header.Set("Cf-Connecting-IP", "1.1.1.1")
		})
		require.NoError(t, err)
		defer resp.Body.Close()
		body, err := io.ReadAll(resp.Body)
		require.NoError(t, err)
		require.Equal(t, proxyTestAppBody, string(body))
		require.Equal(t, http.StatusOK, resp.StatusCode)
		require.Equal(t, "1.1.1.1,127.0.0.1", resp.Header.Get("X-Forwarded-For"))
	})

	t.Run("ProxyError", func(t *testing.T) {
		t.Parallel()

		ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
		defer cancel()

		resp, err := client.Request(ctx, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s/", workspace.Name, proxyTestAppNameFake), nil)
		require.NoError(t, err)
		defer resp.Body.Close()
		require.Equal(t, http.StatusBadGateway, resp.StatusCode)
	})
}

func TestWorkspaceApplicationAuth(t *testing.T) {
	t.Parallel()

	// The OK test checks the entire end-to-end flow of authentication.
	t.Run("End-to-End", func(t *testing.T) {
		t.Parallel()

		client, firstUser, workspace, _ := setupProxyTest(t, nil)

		ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
		defer cancel()

		// Get the current user and API key.
		user, err := client.User(ctx, codersdk.Me)
		require.NoError(t, err)
		currentAPIKey, err := client.GetAPIKey(ctx, firstUser.UserID.String(), strings.Split(client.SessionToken(), "-")[0])
		require.NoError(t, err)

		// Try to load the application without authentication.
		subdomain := fmt.Sprintf("%s--%s--%s--%s", proxyTestAppNameOwner, proxyTestAgentName, workspace.Name, user.Username)
		u, err := url.Parse(fmt.Sprintf("http://%s.%s/test", subdomain, proxyTestSubdomain))
		require.NoError(t, err)
		req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
		require.NoError(t, err)

		var resp *http.Response
		resp, err = doWithRetries(t, client, req)
		require.NoError(t, err)
		resp.Body.Close()

		// Check that the Location is correct.
		require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
		gotLocation, err := resp.Location()
		require.NoError(t, err)
		require.Equal(t, client.URL.Host, gotLocation.Host)
		require.Equal(t, "/api/v2/applications/auth-redirect", gotLocation.Path)
		require.Equal(t, u.String(), gotLocation.Query().Get("redirect_uri"))

		// Load the application auth-redirect endpoint.
		resp, err = requestWithRetries(ctx, t, client, http.MethodGet, "/api/v2/applications/auth-redirect", nil, codersdk.WithQueryParam(
			"redirect_uri", u.String(),
		))
		require.NoError(t, err)
		defer resp.Body.Close()

		require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
		gotLocation, err = resp.Location()
		require.NoError(t, err)

		// Copy the query parameters and then check equality.
		u.RawQuery = gotLocation.RawQuery
		require.Equal(t, u, gotLocation)

		// Verify the API key is set.
		var encryptedAPIKey string
		for k, v := range gotLocation.Query() {
			// The query parameter may change dynamically in the future and is
			// not exported, so we just use a fuzzy check instead.
			if strings.Contains(k, "api_key") {
				encryptedAPIKey = v[0]
			}
		}
		require.NotEmpty(t, encryptedAPIKey, "no API key was set in the query parameters")

		// Decrypt the API key by following the request.
		t.Log("navigating to: ", gotLocation.String())
		req, err = http.NewRequestWithContext(ctx, "GET", gotLocation.String(), nil)
		require.NoError(t, err)
		resp, err = doWithRetries(t, client, req)
		require.NoError(t, err)
		resp.Body.Close()
		require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
		cookies := resp.Cookies()
		require.Len(t, cookies, 1)
		apiKey := cookies[0].Value

		// Fetch the API key.
		apiKeyInfo, err := client.GetAPIKey(ctx, firstUser.UserID.String(), strings.Split(apiKey, "-")[0])
		require.NoError(t, err)
		require.Equal(t, user.ID, apiKeyInfo.UserID)
		require.Equal(t, codersdk.LoginTypePassword, apiKeyInfo.LoginType)
		require.WithinDuration(t, currentAPIKey.ExpiresAt, apiKeyInfo.ExpiresAt, 5*time.Second)

		// Verify the API key permissions
		appClient := codersdk.New(client.URL)
		appClient.SetSessionToken(apiKey)
		appClient.HTTPClient.CheckRedirect = client.HTTPClient.CheckRedirect
		appClient.HTTPClient.Transport = client.HTTPClient.Transport

		var (
			canCreateApplicationConnect = "can-create-application_connect"
			canReadUserMe               = "can-read-user-me"
		)
		authRes, err := appClient.CheckAuthorization(ctx, codersdk.AuthorizationRequest{
			Checks: map[string]codersdk.AuthorizationCheck{
				canCreateApplicationConnect: {
					Object: codersdk.AuthorizationObject{
						ResourceType:   "application_connect",
						OwnerID:        "me",
						OrganizationID: firstUser.OrganizationID.String(),
					},
					Action: "create",
				},
				canReadUserMe: {
					Object: codersdk.AuthorizationObject{
						ResourceType: "user",
						OwnerID:      "me",
						ResourceID:   firstUser.UserID.String(),
					},
					Action: "read",
				},
			},
		})
		require.NoError(t, err)

		require.True(t, authRes[canCreateApplicationConnect])
		require.False(t, authRes[canReadUserMe])

		// Load the application page with the API key set.
		gotLocation, err = resp.Location()
		require.NoError(t, err)
		t.Log("navigating to: ", gotLocation.String())
		req, err = http.NewRequestWithContext(ctx, "GET", gotLocation.String(), nil)
		require.NoError(t, err)
		req.Header.Set(codersdk.SessionCustomHeader, apiKey)
		resp, err = doWithRetries(t, client, req)
		require.NoError(t, err)
		resp.Body.Close()
		require.Equal(t, http.StatusOK, resp.StatusCode)
	})

	t.Run("VerifyRedirectURI", func(t *testing.T) {
		t.Parallel()

		client, _, _, _ := setupProxyTest(t, nil)

		cases := []struct {
			name            string
			redirectURI     string
			status          int
			messageContains string
		}{
			{
				name:            "NoRedirectURI",
				redirectURI:     "",
				status:          http.StatusBadRequest,
				messageContains: "Missing redirect_uri query parameter",
			},
			{
				name:            "InvalidURI",
				redirectURI:     "not a url",
				status:          http.StatusBadRequest,
				messageContains: "Invalid redirect_uri query parameter",
			},
			{
				name:            "NotMatchAppHostname",
				redirectURI:     "https://app--agent--workspace--user.not-a-match.com",
				status:          http.StatusBadRequest,
				messageContains: "The redirect_uri query parameter must be a valid app subdomain",
			},
			{
				name:            "InvalidAppURL",
				redirectURI:     "https://not-an-app." + proxyTestSubdomain,
				status:          http.StatusBadRequest,
				messageContains: "The redirect_uri query parameter must be a valid app subdomain",
			},
		}

		for _, c := range cases {
			c := c
			t.Run(c.name, func(t *testing.T) {
				t.Parallel()

				ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
				defer cancel()

				resp, err := requestWithRetries(ctx, t, client, http.MethodGet, "/api/v2/applications/auth-redirect", nil,
					codersdk.WithQueryParam("redirect_uri", c.redirectURI),
				)
				require.NoError(t, err)
				defer resp.Body.Close()
				require.Equal(t, http.StatusBadRequest, resp.StatusCode)
			})
		}
	})
}

// This test ensures that the subdomain handler does nothing if --app-hostname
// is not set by the admin.
func TestWorkspaceAppsProxySubdomainPassthrough(t *testing.T) {
	t.Parallel()

	// No AppHostname set.
	client := coderdtest.New(t, &coderdtest.Options{
		AppHostname: "",
	})
	firstUser := coderdtest.CreateFirstUser(t, client)

	// Configure the HTTP client to always route all requests to the coder test
	// server.
	defaultTransport, ok := http.DefaultTransport.(*http.Transport)
	require.True(t, ok)
	transport := defaultTransport.Clone()
	transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
		return (&net.Dialer{}).DialContext(ctx, network, client.URL.Host)
	}
	client.HTTPClient.Transport = transport
	t.Cleanup(func() {
		transport.CloseIdleConnections()
	})

	ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
	defer cancel()

	uri := fmt.Sprintf("http://app--agent--workspace--username.%s/api/v2/users/me", proxyTestSubdomain)
	resp, err := requestWithRetries(ctx, t, client, http.MethodGet, uri, nil)
	require.NoError(t, err)
	defer resp.Body.Close()

	// Should look like a codersdk.User response.
	require.Equal(t, http.StatusOK, resp.StatusCode)
	var user codersdk.User
	err = json.NewDecoder(resp.Body).Decode(&user)
	require.NoError(t, err)
	require.Equal(t, firstUser.UserID, user.ID)
}

// This test ensures that the subdomain handler blocks the request if it looks
// like a workspace app request but the configured app hostname differs from the
// request, or the request is not a valid app subdomain but the hostname
// matches.
func TestWorkspaceAppsProxySubdomainBlocked(t *testing.T) {
	t.Parallel()

	setup := func(t *testing.T, appHostname string) *codersdk.Client {
		client := coderdtest.New(t, &coderdtest.Options{
			AppHostname: appHostname,
		})
		_ = coderdtest.CreateFirstUser(t, client)

		// Configure the HTTP client to always route all requests to the coder test
		// server.
		defaultTransport, ok := http.DefaultTransport.(*http.Transport)
		require.True(t, ok)
		transport := defaultTransport.Clone()
		transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
			return (&net.Dialer{}).DialContext(ctx, network, client.URL.Host)
		}
		client.HTTPClient.Transport = transport
		t.Cleanup(func() {
			transport.CloseIdleConnections()
		})

		return client
	}

	t.Run("InvalidSubdomain", func(t *testing.T) {
		t.Parallel()
		client := setup(t, proxyTestSubdomainRaw)

		ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
		defer cancel()

		uri := fmt.Sprintf("http://not-an-app-subdomain.%s/api/v2/users/me", proxyTestSubdomain)
		resp, err := requestWithRetries(ctx, t, client, http.MethodGet, uri, nil)
		require.NoError(t, err)
		defer resp.Body.Close()

		// Should have a HTML error response.
		require.Equal(t, http.StatusBadRequest, resp.StatusCode)
		body, err := io.ReadAll(resp.Body)
		require.NoError(t, err)
		require.Contains(t, string(body), "Could not parse subdomain application URL")
	})
}

func TestWorkspaceAppsProxySubdomain(t *testing.T) {
	t.Parallel()
	client, firstUser, _, port := setupProxyTest(t, nil)

	// proxyURL generates a URL for the proxy subdomain. The default path is a
	// slash.
	proxyURL := func(t *testing.T, client *codersdk.Client, appNameOrPort interface{}, pathAndQuery ...string) string {
		t.Helper()

		var (
			appName string
			port    uint16
		)
		if val, ok := appNameOrPort.(string); ok {
			appName = val
		} else {
			port, ok = appNameOrPort.(uint16)
			require.True(t, ok)
		}

		ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
		defer cancel()

		me, err := client.User(ctx, codersdk.Me)
		require.NoError(t, err, "get current user details")

		res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
			Owner: codersdk.Me,
		})
		require.NoError(t, err, "get workspaces")
		require.Len(t, res.Workspaces, 1, "expected 1 workspace")

		appHost, err := client.GetAppHost(ctx)
		require.NoError(t, err, "get app host")

		subdomain := httpapi.ApplicationURL{
			AppSlug:       appName,
			Port:          port,
			AgentName:     proxyTestAgentName,
			WorkspaceName: res.Workspaces[0].Name,
			Username:      me.Username,
		}.String()

		hostname := strings.Replace(appHost.Host, "*", subdomain, 1)

		actualPath := "/"
		query := ""
		if len(pathAndQuery) > 0 {
			actualPath = pathAndQuery[0]
		}
		if len(pathAndQuery) > 1 {
			query = pathAndQuery[1]
		}

		return (&url.URL{
			Scheme:   "http",
			Host:     hostname,
			Path:     actualPath,
			RawQuery: query,
		}).String()
	}

	t.Run("NoAccessShould401", func(t *testing.T) {
		t.Parallel()

		userClient := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID, rbac.RoleMember())
		userClient.HTTPClient.CheckRedirect = client.HTTPClient.CheckRedirect
		userClient.HTTPClient.Transport = client.HTTPClient.Transport

		ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
		defer cancel()

		resp, err := requestWithRetries(ctx, t, userClient, http.MethodGet, proxyURL(t, client, proxyTestAppNameOwner), nil)
		require.NoError(t, err)
		defer resp.Body.Close()
		require.Equal(t, http.StatusNotFound, resp.StatusCode)
	})

	t.Run("RedirectsWithSlash", func(t *testing.T) {
		t.Parallel()

		ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
		defer cancel()

		slashlessURL := proxyURL(t, client, proxyTestAppNameOwner, "")
		resp, err := requestWithRetries(ctx, t, client, http.MethodGet, slashlessURL, nil)
		require.NoError(t, err)
		defer resp.Body.Close()
		require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)

		loc, err := resp.Location()
		require.NoError(t, err)
		require.Equal(t, slashlessURL+"/?"+proxyTestAppQuery, loc.String())
	})

	t.Run("RedirectsWithQuery", func(t *testing.T) {
		t.Parallel()

		ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
		defer cancel()

		querylessURL := proxyURL(t, client, proxyTestAppNameOwner, "/", "")
		resp, err := requestWithRetries(ctx, t, client, http.MethodGet, querylessURL, nil)
		require.NoError(t, err)
		defer resp.Body.Close()
		require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)

		loc, err := resp.Location()
		require.NoError(t, err)
		require.Equal(t, proxyTestAppQuery, loc.RawQuery)
	})

	t.Run("Proxies", func(t *testing.T) {
		t.Parallel()

		ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
		defer cancel()

		resp, err := requestWithRetries(ctx, t, client, http.MethodGet, proxyURL(t, client, proxyTestAppNameOwner, "/", proxyTestAppQuery), nil)
		require.NoError(t, err)
		defer resp.Body.Close()
		body, err := io.ReadAll(resp.Body)
		require.NoError(t, err)
		require.Equal(t, proxyTestAppBody, string(body))
		require.Equal(t, http.StatusOK, resp.StatusCode)
	})

	t.Run("ProxiesPort", func(t *testing.T) {
		t.Parallel()

		ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
		defer cancel()

		resp, err := requestWithRetries(ctx, t, client, http.MethodGet, proxyURL(t, client, port, "/", proxyTestAppQuery), nil)
		require.NoError(t, err)
		defer resp.Body.Close()
		body, err := io.ReadAll(resp.Body)
		require.NoError(t, err)
		require.Equal(t, proxyTestAppBody, string(body))
		require.Equal(t, http.StatusOK, resp.StatusCode)
	})

	t.Run("ProxyError", func(t *testing.T) {
		t.Parallel()

		ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
		defer cancel()

		resp, err := client.Request(ctx, http.MethodGet, proxyURL(t, client, proxyTestAppNameFake, "/", ""), nil)
		require.NoError(t, err)
		defer resp.Body.Close()
		require.Equal(t, http.StatusBadGateway, resp.StatusCode)
	})

	t.Run("ProxyPortMinimumError", func(t *testing.T) {
		t.Parallel()

		ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
		defer cancel()

		port := uint16(codersdk.MinimumListeningPort - 1)
		resp, err := requestWithRetries(ctx, t, client, http.MethodGet, proxyURL(t, client, port, "/", proxyTestAppQuery), nil)
		require.NoError(t, err)
		defer resp.Body.Close()

		// Should have an error response.
		require.Equal(t, http.StatusBadRequest, resp.StatusCode)
		var resBody codersdk.Response
		err = json.NewDecoder(resp.Body).Decode(&resBody)
		require.NoError(t, err)
		require.Contains(t, resBody.Message, "Coder reserves ports less than")
	})

	t.Run("SuffixWildcardOK", func(t *testing.T) {
		t.Parallel()

		client, _, _, _ := setupProxyTest(t, &setupProxyTestOpts{
			AppHost: "*-suffix.test.coder.com",
		})

		ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
		defer cancel()

		u := proxyURL(t, client, proxyTestAppNameOwner, "/", proxyTestAppQuery)
		t.Logf("url: %s", u)

		resp, err := requestWithRetries(ctx, t, client, http.MethodGet, u, nil)
		require.NoError(t, err)
		defer resp.Body.Close()
		body, err := io.ReadAll(resp.Body)
		require.NoError(t, err)
		require.Equal(t, proxyTestAppBody, string(body))
		require.Equal(t, http.StatusOK, resp.StatusCode)
	})

	t.Run("SuffixWildcardNotMatch", func(t *testing.T) {
		t.Parallel()

		client, _, _, _ := setupProxyTest(t, &setupProxyTestOpts{
			AppHost: "*-suffix.test.coder.com",
		})

		t.Run("NoSuffix", func(t *testing.T) {
			ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
			defer cancel()

			u := proxyURL(t, client, proxyTestAppNameOwner, "/", proxyTestAppQuery)
			// Replace the -suffix with nothing.
			u = strings.Replace(u, "-suffix", "", 1)

			resp, err := requestWithRetries(ctx, t, client, http.MethodGet, u, nil)
			require.NoError(t, err)
			defer resp.Body.Close()
			body, err := io.ReadAll(resp.Body)
			require.NoError(t, err)

			// It's probably rendering the dashboard, so only ensure that the body
			// doesn't match.
			require.NotContains(t, string(body), proxyTestAppBody)
		})

		t.Run("DifferentSuffix", func(t *testing.T) {
			t.Parallel()

			ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
			defer cancel()

			u := proxyURL(t, client, proxyTestAppNameOwner, "/", proxyTestAppQuery)
			// Replace the -suffix with something else.
			u = strings.Replace(u, "-suffix", "-not-suffix", 1)

			resp, err := requestWithRetries(ctx, t, client, http.MethodGet, u, nil)
			require.NoError(t, err)
			defer resp.Body.Close()
			body, err := io.ReadAll(resp.Body)
			require.NoError(t, err)

			// It's probably rendering the dashboard, so only ensure that the body
			// doesn't match.
			require.NotContains(t, string(body), proxyTestAppBody)
		})
	})
}

func TestAppSubdomainLogout(t *testing.T) {
	t.Parallel()

	keyID, keySecret, err := coderd.GenerateAPIKeyIDSecret()
	require.NoError(t, err)
	fakeAPIKey := fmt.Sprintf("%s-%s", keyID, keySecret)

	cases := []struct {
		name string
		// The cookie to send with the request. The regular API key header
		// is also sent to bypass any auth checks on this value, and to
		// ensure that the logout code is safe when multiple keys are
		// passed.
		// Empty value means no cookie is sent, "-" means send a valid
		// API key, and "bad-secret" means send a valid key ID with a bad
		// secret.
		cookie string
		// You can use "access_url" to use the site access URL as the
		// redirect URI, or "app_host" to use a valid app host.
		redirectURI string

		// If expectedStatus is not an error status, we expect the cookie to
		// be deleted if it was set.
		expectedStatus       int
		expectedBodyContains string
		// If empty, the expected location is the redirectURI if the
		// expected status code is http.StatusTemporaryRedirect (using the
		// access URL if not set).
		// You can use "access_url" to force the access URL.
		expectedLocation string
	}{
		{
			name:           "OKAccessURL",
			cookie:         "-",
			redirectURI:    "access_url",
			expectedStatus: http.StatusTemporaryRedirect,
		},
		{
			name:           "OKAppHost",
			cookie:         "-",
			redirectURI:    "app_host",
			expectedStatus: http.StatusTemporaryRedirect,
		},
		{
			name:        "OKNoAPIKey",
			cookie:      "",
			redirectURI: "access_url",
			// Even if the devurl cookie is missing, we still redirect without
			// any complaints.
			expectedStatus: http.StatusTemporaryRedirect,
		},
		{
			name:        "OKBadAPIKey",
			cookie:      "test-api-key",
			redirectURI: "access_url",
			// Even if the devurl cookie is bad, we still delete the cookie and
			// redirect without any complaints.
			expectedStatus: http.StatusTemporaryRedirect,
		},
		{
			name:           "OKUnknownAPIKey",
			cookie:         fakeAPIKey,
			redirectURI:    "access_url",
			expectedStatus: http.StatusTemporaryRedirect,
		},
		{
			name:                 "BadAPIKeySecret",
			cookie:               "bad-secret",
			redirectURI:          "access_url",
			expectedStatus:       http.StatusUnauthorized,
			expectedBodyContains: "API key secret is invalid",
		},
		{
			name:                 "InvalidRedirectURI",
			cookie:               "-",
			redirectURI:          string([]byte{0x00}),
			expectedStatus:       http.StatusBadRequest,
			expectedBodyContains: "Could not parse redirect URI",
		},
		{
			name:        "DisallowedRedirectURI",
			cookie:      "-",
			redirectURI: "https://github.com/coder/coder",
			// We don't allow redirecting to a different host, but we don't
			// show an error page and just redirect to the access URL to avoid
			// breaking the logout flow if the user is accessing from the wrong
			// host.
			expectedStatus:   http.StatusTemporaryRedirect,
			expectedLocation: "access_url",
		},
	}

	for _, c := range cases {
		c := c

		t.Run(c.name, func(t *testing.T) {
			t.Parallel()

			client, _, _, _ := setupProxyTest(t, nil)

			ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
			defer cancel()

			// The token should work.
			_, err := client.User(ctx, codersdk.Me)
			require.NoError(t, err)

			appHost, err := client.GetAppHost(ctx)
			require.NoError(t, err, "get app host")

			if c.cookie == "-" {
				c.cookie = client.SessionToken()
			} else if c.cookie == "bad-secret" {
				keyID, _, err := httpmw.SplitAPIToken(client.SessionToken())
				require.NoError(t, err)
				c.cookie = fmt.Sprintf("%s-%s", keyID, keySecret)
			}
			if c.redirectURI == "access_url" {
				c.redirectURI = client.URL.String()
			} else if c.redirectURI == "app_host" {
				c.redirectURI = "http://" + strings.Replace(appHost.Host, "*", "something--something--something--something", 1) + "/"
			}
			if c.expectedLocation == "" && c.expectedStatus == http.StatusTemporaryRedirect {
				c.expectedLocation = c.redirectURI
			}
			if c.expectedLocation == "access_url" {
				c.expectedLocation = client.URL.String()
			}

			logoutURL := &url.URL{
				Scheme: "http",
				Host:   strings.Replace(appHost.Host, "*", "coder-logout", 1),
				Path:   "/",
			}
			if c.redirectURI != "" {
				q := logoutURL.Query()
				q.Set("redirect_uri", c.redirectURI)
				logoutURL.RawQuery = q.Encode()
			}

			req, err := http.NewRequestWithContext(ctx, http.MethodGet, logoutURL.String(), nil)
			require.NoError(t, err, "create logout request")
			// The header is prioritized over the devurl cookie if both are
			// set, so this ensures we can trigger the logout code path with
			// bad cookies during tests.
			req.Header.Set(codersdk.SessionCustomHeader, client.SessionToken())
			if c.cookie != "" {
				req.AddCookie(&http.Cookie{
					Name:  httpmw.DevURLSessionTokenCookie,
					Value: c.cookie,
				})
			}

			client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
				return http.ErrUseLastResponse
			}
			resp, err := client.HTTPClient.Do(req)
			require.NoError(t, err, "do logout request")
			defer resp.Body.Close()

			require.Equal(t, c.expectedStatus, resp.StatusCode, "logout response status code")
			if c.expectedStatus < 400 && c.cookie != "" {
				cookies := resp.Cookies()
				require.Len(t, cookies, 1, "logout response cookies")
				cookie := cookies[0]
				require.Equal(t, httpmw.DevURLSessionTokenCookie, cookie.Name)
				require.Equal(t, "", cookie.Value)
				require.True(t, cookie.Expires.Before(time.Now()), "cookie should be expired")

				// The token shouldn't work anymore if it was the original valid
				// session token.
				if c.cookie == client.SessionToken() {
					_, err = client.User(ctx, codersdk.Me)
					require.Error(t, err)
				}
			}
			if c.expectedBodyContains != "" {
				body, err := io.ReadAll(resp.Body)
				require.NoError(t, err)
				require.Contains(t, string(body), c.expectedBodyContains, "logout response body")
			}
			if c.expectedLocation != "" {
				location := resp.Header.Get("Location")
				require.Equal(t, c.expectedLocation, location, "logout response location")
			}
		})
	}
}

func TestAppSharing(t *testing.T) {
	t.Parallel()

	setup := func(t *testing.T, allowPathAppSharing, allowSiteOwnerAccess bool) (workspace codersdk.Workspace, agnt codersdk.WorkspaceAgent, user codersdk.User, ownerClient *codersdk.Client, client *codersdk.Client, clientInOtherOrg *codersdk.Client, clientWithNoAuth *codersdk.Client) {
		//nolint:gosec
		const password = "password"

		var port uint16
		ownerClient, _, _, port = setupProxyTest(t, &setupProxyTestOpts{
			NoWorkspace:                          true,
			DangerousAllowPathAppSharing:         allowPathAppSharing,
			DangerousAllowPathAppSiteOwnerAccess: allowSiteOwnerAccess,
		})
		forceURLTransport(t, ownerClient)

		ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
		t.Cleanup(cancel)

		ownerUser, err := ownerClient.User(ctx, codersdk.Me)
		require.NoError(t, err)

		// Create a template-admin user in the same org. We don't use an owner
		// since they have access to everything.
		user, err = ownerClient.CreateUser(ctx, codersdk.CreateUserRequest{
			Email:          "user@coder.com",
			Username:       "user",
			Password:       password,
			OrganizationID: ownerUser.OrganizationIDs[0],
		})
		require.NoError(t, err)

		_, err = ownerClient.UpdateUserRoles(ctx, user.ID.String(), codersdk.UpdateRoles{
			Roles: []string{"template-admin", "member"},
		})
		require.NoError(t, err)

		client = codersdk.New(ownerClient.URL)
		loginRes, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
			Email:    user.Email,
			Password: password,
		})
		require.NoError(t, err)
		client.SetSessionToken(loginRes.SessionToken)
		client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
			return http.ErrUseLastResponse
		}
		forceURLTransport(t, client)

		// Create workspace.
		workspace = createWorkspaceWithApps(t, client, user.OrganizationIDs[0], proxyTestSubdomainRaw, port)

		// Verify that the apps have the correct sharing levels set.
		workspaceBuild, err := client.WorkspaceBuild(ctx, workspace.LatestBuild.ID)
		require.NoError(t, err)
		agnt = workspaceBuild.Resources[0].Agents[0]
		found := map[string]codersdk.WorkspaceAppSharingLevel{}
		expected := map[string]codersdk.WorkspaceAppSharingLevel{
			proxyTestAppNameFake:          codersdk.WorkspaceAppSharingLevelOwner,
			proxyTestAppNameOwner:         codersdk.WorkspaceAppSharingLevelOwner,
			proxyTestAppNameAuthenticated: codersdk.WorkspaceAppSharingLevelAuthenticated,
			proxyTestAppNamePublic:        codersdk.WorkspaceAppSharingLevelPublic,
		}
		for _, app := range agnt.Apps {
			found[app.DisplayName] = app.SharingLevel
		}
		require.Equal(t, expected, found, "apps have incorrect sharing levels")

		// Create a user in a different org.
		otherOrg, err := ownerClient.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{
			Name: "a-different-org",
		})
		require.NoError(t, err)
		userInOtherOrg, err := ownerClient.CreateUser(ctx, codersdk.CreateUserRequest{
			Email:          "no-template-access@coder.com",
			Username:       "no-template-access",
			Password:       password,
			OrganizationID: otherOrg.ID,
		})
		require.NoError(t, err)

		clientInOtherOrg = codersdk.New(client.URL)
		loginRes, err = clientInOtherOrg.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
			Email:    userInOtherOrg.Email,
			Password: password,
		})
		require.NoError(t, err)
		clientInOtherOrg.SetSessionToken(loginRes.SessionToken)
		clientInOtherOrg.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
			return http.ErrUseLastResponse
		}
		forceURLTransport(t, clientInOtherOrg)

		// Create an unauthenticated codersdk client.
		clientWithNoAuth = codersdk.New(client.URL)
		clientWithNoAuth.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
			return http.ErrUseLastResponse
		}
		forceURLTransport(t, clientWithNoAuth)

		return workspace, agnt, user, ownerClient, client, clientInOtherOrg, clientWithNoAuth
	}

	verifyAccess := func(t *testing.T, isPathApp bool, username, workspaceName, agentName, appName string, client *codersdk.Client, shouldHaveAccess, shouldRedirectToLogin bool) {
		t.Helper()

		ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
		defer cancel()

		// If the client has a session token, we also want to check that a
		// scoped key works.
		clients := []*codersdk.Client{client}
		if client.SessionToken() != "" {
			token, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
				Scope: codersdk.APIKeyScopeApplicationConnect,
			})
			require.NoError(t, err)

			scopedClient := codersdk.New(client.URL)
			scopedClient.SetSessionToken(token.Key)
			scopedClient.HTTPClient.CheckRedirect = client.HTTPClient.CheckRedirect
			scopedClient.HTTPClient.Transport = client.HTTPClient.Transport

			clients = append(clients, scopedClient)
		}

		for i, client := range clients {
			msg := fmt.Sprintf("client %d", i)

			u := fmt.Sprintf("/@%s/%s.%s/apps/%s/?%s", username, workspaceName, agentName, appName, proxyTestAppQuery)
			if !isPathApp {
				subdomain := httpapi.ApplicationURL{
					AppSlug:       appName,
					AgentName:     agentName,
					WorkspaceName: workspaceName,
					Username:      username,
				}.String()

				hostname := strings.Replace(proxyTestSubdomainRaw, "*", subdomain, 1)
				u = fmt.Sprintf("http://%s/?%s", hostname, proxyTestAppQuery)
			}

			res, err := requestWithRetries(ctx, t, client, http.MethodGet, u, nil)
			require.NoError(t, err, msg)

			dump, err := httputil.DumpResponse(res, true)
			_ = res.Body.Close()
			require.NoError(t, err, msg)
			// t.Logf("response dump: %s", dump)

			if !shouldHaveAccess {
				if shouldRedirectToLogin {
					assert.Equal(t, http.StatusTemporaryRedirect, res.StatusCode, "should not have access, expected temporary redirect. "+msg)
					location, err := res.Location()
					require.NoError(t, err, msg)

					expectedPath := "/login"
					if !isPathApp {
						expectedPath = "/api/v2/applications/auth-redirect"
					}
					assert.Equal(t, expectedPath, location.Path, "should not have access, expected redirect to applicable login endpoint. "+msg)
				} else {
					// If the user doesn't have access we return 404 to avoid
					// leaking information about the existence of the app.
					assert.Equal(t, http.StatusNotFound, res.StatusCode, "should not have access, expected not found. "+msg)
				}
			}

			if shouldHaveAccess {
				assert.Equal(t, http.StatusOK, res.StatusCode, "should have access, expected ok. "+msg)
				assert.Contains(t, string(dump), "hello world", "should have access, expected hello world. "+msg)
			}
		}
	}

	testLevels := func(t *testing.T, isPathApp, pathAppSharingEnabled, siteOwnerPathAppAccessEnabled bool) {
		workspace, agnt, user, ownerClient, client, clientInOtherOrg, clientWithNoAuth := setup(t, pathAppSharingEnabled, siteOwnerPathAppAccessEnabled)

		allowedUnlessSharingDisabled := !isPathApp || pathAppSharingEnabled
		siteOwnerCanAccess := !isPathApp || siteOwnerPathAppAccessEnabled
		siteOwnerCanAccessShared := siteOwnerCanAccess || pathAppSharingEnabled

		deploymentConfig, err := ownerClient.DeploymentConfig(context.Background())
		require.NoError(t, err)

		assert.Equal(t, pathAppSharingEnabled, deploymentConfig.Dangerous.AllowPathAppSharing.Value)
		assert.Equal(t, siteOwnerPathAppAccessEnabled, deploymentConfig.Dangerous.AllowPathAppSiteOwnerAccess.Value)

		t.Run("LevelOwner", func(t *testing.T) {
			t.Parallel()

			// Site owner should be able to access all workspaces if
			// enabled.
			verifyAccess(t, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameOwner, ownerClient, siteOwnerCanAccess, false)

			// Owner should be able to access their own workspace.
			verifyAccess(t, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameOwner, client, true, false)

			// Authenticated users should not have access to a workspace that
			// they do not own.
			verifyAccess(t, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameOwner, clientInOtherOrg, false, false)

			// Unauthenticated user should not have any access.
			verifyAccess(t, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameOwner, clientWithNoAuth, false, true)
		})

		t.Run("LevelAuthenticated", func(t *testing.T) {
			t.Parallel()

			// Site owner should be able to access all workspaces if
			// enabled.
			verifyAccess(t, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameAuthenticated, ownerClient, siteOwnerCanAccessShared, false)

			// Owner should be able to access their own workspace.
			verifyAccess(t, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameAuthenticated, client, true, false)

			// Authenticated users should be able to access the workspace.
			verifyAccess(t, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameAuthenticated, clientInOtherOrg, allowedUnlessSharingDisabled, false)

			// Unauthenticated user should not have any access.
			verifyAccess(t, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameAuthenticated, clientWithNoAuth, false, true)
		})

		t.Run("LevelPublic", func(t *testing.T) {
			t.Parallel()

			// Site owner should be able to access all workspaces if
			// enabled.
			verifyAccess(t, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNamePublic, ownerClient, siteOwnerCanAccessShared, false)

			// Owner should be able to access their own workspace.
			verifyAccess(t, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNamePublic, client, true, false)

			// Authenticated users should be able to access the workspace.
			verifyAccess(t, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNamePublic, clientInOtherOrg, allowedUnlessSharingDisabled, false)

			// Unauthenticated user should be able to access the workspace.
			verifyAccess(t, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNamePublic, clientWithNoAuth, allowedUnlessSharingDisabled, !allowedUnlessSharingDisabled)
		})
	}

	t.Run("Path", func(t *testing.T) {
		t.Parallel()

		t.Run("Default", func(t *testing.T) {
			t.Parallel()
			testLevels(t, true, false, false)
		})

		t.Run("AppSharingEnabled", func(t *testing.T) {
			t.Parallel()
			testLevels(t, true, true, false)
		})

		t.Run("SiteOwnerAccessEnabled", func(t *testing.T) {
			t.Parallel()
			testLevels(t, true, false, true)
		})

		t.Run("BothEnabled", func(t *testing.T) {
			t.Parallel()
			testLevels(t, true, false, true)
		})
	})

	t.Run("Subdomain", func(t *testing.T) {
		t.Parallel()
		testLevels(t, false, false, false)
	})
}

func TestWorkspaceAppsNonCanonicalHeaders(t *testing.T) {
	t.Parallel()

	setupNonCanonicalHeadersTest := func(t *testing.T, customAppHost ...string) (*codersdk.Client, codersdk.CreateFirstUserResponse, codersdk.Workspace, uint16) {
		// Start a TCP server that manually parses the request. Golang's HTTP
		// server canonicalizes all HTTP request headers it receives, so we
		// can't use it to test that we forward non-canonical headers.
		// #nosec
		ln, err := net.Listen("tcp", ":0")
		require.NoError(t, err)
		go func() {
			for {
				c, err := ln.Accept()
				if xerrors.Is(err, net.ErrClosed) {
					return
				}
				require.NoError(t, err)

				go func() {
					s := bufio.NewScanner(c)

					// Read request line.
					assert.True(t, s.Scan())
					reqLine := s.Text()
					assert.True(t, strings.HasPrefix(reqLine, fmt.Sprintf("GET /?%s HTTP/1.1", proxyTestAppQuery)))

					// Read headers and discard them. We collect the
					// Sec-WebSocket-Key header (with a capital S) to respond
					// with.
					secWebSocketKey := "(none found)"
					for s.Scan() {
						if s.Text() == "" {
							break
						}

						line := strings.TrimSpace(s.Text())
						if strings.HasPrefix(line, "Sec-WebSocket-Key: ") {
							secWebSocketKey = strings.TrimPrefix(line, "Sec-WebSocket-Key: ")
						}
					}

					// Write response containing text/plain with the
					// Sec-WebSocket-Key header.
					res := fmt.Sprintf("HTTP/1.1 204 No Content\r\nSec-WebSocket-Key: %s\r\nConnection: close\r\n\r\n", secWebSocketKey)
					_, err = c.Write([]byte(res))
					assert.NoError(t, err)
					err = c.Close()
					assert.NoError(t, err)
				}()
			}
		}()
		t.Cleanup(func() {
			_ = ln.Close()
		})
		tcpAddr, ok := ln.Addr().(*net.TCPAddr)
		require.True(t, ok)

		appHost := proxyTestSubdomainRaw
		if len(customAppHost) > 0 {
			appHost = customAppHost[0]
		}

		client := coderdtest.New(t, &coderdtest.Options{
			AppHostname:                 appHost,
			IncludeProvisionerDaemon:    true,
			AgentStatsRefreshInterval:   time.Millisecond * 100,
			MetricsCacheRefreshInterval: time.Millisecond * 100,
			RealIPConfig: &httpmw.RealIPConfig{
				TrustedOrigins: []*net.IPNet{{
					IP:   net.ParseIP("127.0.0.1"),
					Mask: net.CIDRMask(8, 32),
				}},
				TrustedHeaders: []string{
					"CF-Connecting-IP",
				},
			},
		})

		user := coderdtest.CreateFirstUser(t, client)

		workspace := createWorkspaceWithApps(t, client, user.OrganizationID, appHost, uint16(tcpAddr.Port))

		// Configure the HTTP client to not follow redirects and to route all
		// requests regardless of hostname to the coderd test server.
		client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
			return http.ErrUseLastResponse
		}
		defaultTransport, ok := http.DefaultTransport.(*http.Transport)
		require.True(t, ok)
		transport := defaultTransport.Clone()
		transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
			return (&net.Dialer{}).DialContext(ctx, network, client.URL.Host)
		}
		client.HTTPClient.Transport = transport
		t.Cleanup(func() {
			transport.CloseIdleConnections()
		})

		return client, user, workspace, uint16(tcpAddr.Port)
	}

	t.Run("ProxyPath", func(t *testing.T) {
		t.Parallel()

		client, _, workspace, _ := setupNonCanonicalHeadersTest(t)

		ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
		defer cancel()

		u, err := client.URL.Parse(fmt.Sprintf("/@me/%s/apps/%s/?%s", workspace.Name, proxyTestAppNameOwner, proxyTestAppQuery))
		require.NoError(t, err)

		req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
		require.NoError(t, err)

		// Use a non-canonical header name. The S in Sec-WebSocket-Key should be
		// capitalized according to the websocket spec, but Golang will
		// lowercase it to match the HTTP/1 spec.
		//
		// Setting the header on the map directly will force the header to not
		// be canonicalized on the client, but it will be canonicalized on the
		// server.
		secWebSocketKey := "test-dean-was-here"
		req.Header["Sec-WebSocket-Key"] = []string{secWebSocketKey}

		req.Header.Set(codersdk.SessionCustomHeader, client.SessionToken())
		resp, err := doWithRetries(t, client, req)
		require.NoError(t, err)
		defer resp.Body.Close()

		// The response should be a 204 No Content with the Sec-WebSocket-Key
		// header set to the value we sent.
		res, err := httputil.DumpResponse(resp, true)
		require.NoError(t, err)
		t.Log(string(res))
		require.Equal(t, http.StatusNoContent, resp.StatusCode)
		require.Equal(t, secWebSocketKey, resp.Header.Get("Sec-WebSocket-Key"))
	})

	t.Run("Subdomain", func(t *testing.T) {
		t.Parallel()

		appHost := proxyTestSubdomainRaw
		client, _, workspace, _ := setupNonCanonicalHeadersTest(t, appHost)

		ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
		defer cancel()

		user, err := client.User(ctx, codersdk.Me)
		require.NoError(t, err)

		u := fmt.Sprintf(
			"http://%s--%s--%s--%s%s?%s",
			proxyTestAppNameOwner,
			proxyTestAgentName,
			workspace.Name,
			user.Username,
			strings.ReplaceAll(appHost, "*", ""),
			proxyTestAppQuery,
		)

		// Re-enable the default redirect behavior.
		client.HTTPClient.CheckRedirect = nil

		req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
		require.NoError(t, err)

		// Use a non-canonical header name. The S in Sec-WebSocket-Key should be
		// capitalized according to the websocket spec, but Golang will
		// lowercase it to match the HTTP/1 spec.
		//
		// Setting the header on the map directly will force the header to not
		// be canonicalized on the client, but it will be canonicalized on the
		// server.
		secWebSocketKey := "test-dean-was-here"
		req.Header["Sec-WebSocket-Key"] = []string{secWebSocketKey}

		req.Header.Set(codersdk.SessionCustomHeader, client.SessionToken())
		resp, err := doWithRetries(t, client, req)
		require.NoError(t, err)
		defer resp.Body.Close()

		// The response should be a 204 No Content with the Sec-WebSocket-Key
		// header set to the value we sent.
		res, err := httputil.DumpResponse(resp, true)
		require.NoError(t, err)
		t.Log(string(res))
		require.Equal(t, http.StatusNoContent, resp.StatusCode)
		require.Equal(t, secWebSocketKey, resp.Header.Get("Sec-WebSocket-Key"))
	})
}

// forceURLTransport forces the client to route all requests to the client's
// configured URL host regardless of hostname.
func forceURLTransport(t *testing.T, client *codersdk.Client) {
	defaultTransport, ok := http.DefaultTransport.(*http.Transport)
	require.True(t, ok)
	transport := defaultTransport.Clone()
	transport.DialContext = func(ctx context.Context, network, _ string) (net.Conn, error) {
		return (&net.Dialer{}).DialContext(ctx, network, client.URL.Host)
	}
	client.HTTPClient.Transport = transport
	t.Cleanup(func() {
		transport.CloseIdleConnections()
	})
}
