Files
coder/coderd/workspaceapps/apptest/apptest.go
Dean Sheather 5398150c25 chore: move workspace apps tests to new package (#7025)
* chore: move workspace apps tests to new package
* chore: move reconnecting pty to apptest package
2023-04-06 16:35:27 -05:00

1152 lines
40 KiB
Go

package apptest
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/cookiejar"
"net/http/httputil"
"net/url"
"runtime"
"strconv"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/testutil"
)
// Run runs the entire workspace app test suite against deployments minted
// by the provided factory.
func Run(t *testing.T, factory DeploymentFactory) {
setupProxyTest := func(t *testing.T, opts *DeploymentOptions) *AppDetails {
return setupProxyTestWithFactory(t, factory, opts)
}
t.Run("ReconnectingPTY", func(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
// This might be our implementation, or ConPTY itself.
// It's difficult to find extensive tests for it, so
// it seems like it could be either.
t.Skip("ConPTY appears to be inconsistent on Windows.")
}
appDetails := setupProxyTest(t, nil)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
// Run the test against the path app hostname since that's where the
// reconnecting-pty proxy server we want to test is mounted.
client := codersdk.New(appDetails.PathAppBaseURL)
client.SetSessionToken(appDetails.Client.SessionToken())
conn, err := client.WorkspaceAgentReconnectingPTY(ctx, appDetails.Agent.ID, uuid.New(), 80, 80, "/bin/bash")
require.NoError(t, err)
defer conn.Close()
// First attempt to resize the TTY.
// The websocket will close if it fails!
data, err := json.Marshal(codersdk.ReconnectingPTYRequest{
Height: 250,
Width: 250,
})
require.NoError(t, err)
_, err = conn.Write(data)
require.NoError(t, err)
bufRead := bufio.NewReader(conn)
// Brief pause to reduce the likelihood that we send keystrokes while
// the shell is simultaneously sending a prompt.
time.Sleep(100 * time.Millisecond)
data, err = json.Marshal(codersdk.ReconnectingPTYRequest{
Data: "echo test\r\n",
})
require.NoError(t, err)
_, err = conn.Write(data)
require.NoError(t, err)
expectLine := func(matcher func(string) bool) {
for {
line, err := bufRead.ReadString('\n')
require.NoError(t, err)
if matcher(line) {
break
}
}
}
matchEchoCommand := func(line string) bool {
return strings.Contains(line, "echo test")
}
matchEchoOutput := func(line string) bool {
return strings.Contains(line, "test") && !strings.Contains(line, "echo")
}
expectLine(matchEchoCommand)
expectLine(matchEchoOutput)
})
t.Run("WorkspaceAppsProxyPath", func(t *testing.T) {
t.Parallel()
appDetails := setupProxyTest(t, nil)
t.Run("Disabled", func(t *testing.T) {
t.Parallel()
appDetails := setupProxyTest(t, &DeploymentOptions{
DisablePathApps: true,
})
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, appDetails.PathAppURL(appDetails.OwnerApp).String(), 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()
// Clone the client to strip auth.
unauthedClient := codersdk.New(appDetails.Client.URL)
unauthedClient.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, unauthedClient, http.MethodGet, appDetails.PathAppURL(appDetails.OwnerApp).String(), 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, appDetails.Client, appDetails.FirstUser.OrganizationID, rbac.RoleMember())
userClient.HTTPClient.CheckRedirect = appDetails.Client.HTTPClient.CheckRedirect
userClient.HTTPClient.Transport = appDetails.Client.HTTPClient.Transport
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := requestWithRetries(ctx, t, userClient, http.MethodGet, appDetails.PathAppURL(appDetails.OwnerApp).String(), 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()
u := appDetails.PathAppURL(appDetails.OwnerApp)
u.Path = strings.TrimSuffix(u.Path, "/")
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), 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()
u := appDetails.PathAppURL(appDetails.OwnerApp)
u.RawQuery = ""
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), 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()
u := appDetails.PathAppURL(appDetails.OwnerApp)
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), 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)
var appTokenCookie *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == codersdk.DevURLSignedAppTokenCookie {
appTokenCookie = c
break
}
}
require.NotNil(t, appTokenCookie, "no signed app token cookie in response")
require.Equal(t, appTokenCookie.Path, u.Path, "incorrect path on app token cookie")
// Ensure the signed app token cookie is valid.
appTokenClient := codersdk.New(appDetails.Client.URL)
appTokenClient.HTTPClient.CheckRedirect = appDetails.Client.HTTPClient.CheckRedirect
appTokenClient.HTTPClient.Transport = appDetails.Client.HTTPClient.Transport
appTokenClient.HTTPClient.Jar, err = cookiejar.New(nil)
require.NoError(t, err)
appTokenClient.HTTPClient.Jar.SetCookies(u, []*http.Cookie{appTokenCookie})
resp, err = requestWithRetries(ctx, t, appTokenClient, http.MethodGet, u.String(), 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("BlocksMe", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
app := appDetails.OwnerApp
app.Username = codersdk.Me
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, appDetails.PathAppURL(app).String(), nil)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusNotFound, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Contains(t, string(body), "must be accessed with the full username, not @me")
})
t.Run("ForwardsIP", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, appDetails.PathAppURL(appDetails.OwnerApp).String(), 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 := appDetails.Client.Request(ctx, http.MethodGet, appDetails.PathAppURL(appDetails.FakeApp).String(), nil)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusBadGateway, resp.StatusCode)
})
t.Run("NoProxyPort", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := appDetails.Client.Request(ctx, http.MethodGet, appDetails.PathAppURL(appDetails.PortApp).String(), nil)
require.NoError(t, err)
defer resp.Body.Close()
// TODO(@deansheather): This should be 400. There's a todo in the
// resolve request code to fix this.
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
})
})
t.Run("WorkspaceApplicationAuth", func(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()
appDetails := setupProxyTest(t, nil)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
// Get the current user and API key.
user, err := appDetails.Client.User(ctx, codersdk.Me)
require.NoError(t, err)
currentAPIKey, err := appDetails.Client.APIKeyByID(ctx, appDetails.FirstUser.UserID.String(), strings.Split(appDetails.Client.SessionToken(), "-")[0])
require.NoError(t, err)
// Try to load the application without authentication.
subdomain := fmt.Sprintf("%s--%s--%s--%s", proxyTestAppNameOwner, proxyTestAgentName, appDetails.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, appDetails.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, appDetails.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, appDetails.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, appDetails.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 := appDetails.Client.APIKeyByID(ctx, appDetails.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)
require.EqualValues(t, currentAPIKey.LifetimeSeconds, apiKeyInfo.LifetimeSeconds)
// Verify the API key permissions
appClient := codersdk.New(appDetails.Client.URL)
appClient.SetSessionToken(apiKey)
appClient.HTTPClient.CheckRedirect = appDetails.Client.HTTPClient.CheckRedirect
appClient.HTTPClient.Transport = appDetails.Client.HTTPClient.Transport
var (
canCreateApplicationConnect = "can-create-application_connect"
canReadUserMe = "can-read-user-me"
)
authRes, err := appClient.AuthCheck(ctx, codersdk.AuthorizationRequest{
Checks: map[string]codersdk.AuthorizationCheck{
canCreateApplicationConnect: {
Object: codersdk.AuthorizationObject{
ResourceType: "application_connect",
OwnerID: "me",
OrganizationID: appDetails.FirstUser.OrganizationID.String(),
},
Action: "create",
},
canReadUserMe: {
Object: codersdk.AuthorizationObject{
ResourceType: "user",
OwnerID: "me",
ResourceID: appDetails.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.SessionTokenHeader, apiKey)
resp, err = doWithRetries(t, appDetails.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()
appDetails := 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, appDetails.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.
t.Run("WorkspaceAppsProxySubdomainPassthrough", func(t *testing.T) {
t.Parallel()
// No Hostname set.
appDetails := setupProxyTest(t, &DeploymentOptions{
AppHost: "",
DisableSubdomainApps: true,
noWorkspace: true,
})
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, appDetails.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, appDetails.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.
t.Run("WorkspaceAppsProxySubdomainBlocked", func(t *testing.T) {
t.Parallel()
appDetails := setupProxyTest(t, &DeploymentOptions{
noWorkspace: true,
})
t.Run("InvalidSubdomain", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
host := strings.Replace(appDetails.Options.AppHost, "*", "not-an-app-subdomain", 1)
uri := fmt.Sprintf("http://%s/api/v2/users/me", host)
resp, err := requestWithRetries(ctx, t, appDetails.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")
})
})
t.Run("WorkspaceAppsProxySubdomain", func(t *testing.T) {
t.Parallel()
appDetails := setupProxyTest(t, nil)
t.Run("NoAccessShould401", func(t *testing.T) {
t.Parallel()
userClient, _ := coderdtest.CreateAnotherUser(t, appDetails.Client, appDetails.FirstUser.OrganizationID, rbac.RoleMember())
userClient.HTTPClient.CheckRedirect = appDetails.Client.HTTPClient.CheckRedirect
userClient.HTTPClient.Transport = appDetails.Client.HTTPClient.Transport
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := requestWithRetries(ctx, t, userClient, http.MethodGet, appDetails.SubdomainAppURL(appDetails.OwnerApp).String(), 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()
u := appDetails.SubdomainAppURL(appDetails.OwnerApp)
u.Path = ""
u.RawQuery = ""
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), 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, appDetails.SubdomainAppURL(appDetails.OwnerApp).Path, loc.Path)
})
t.Run("RedirectsWithQuery", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
u := appDetails.SubdomainAppURL(appDetails.OwnerApp)
u.RawQuery = ""
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), 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, appDetails.SubdomainAppURL(appDetails.OwnerApp).RawQuery, loc.RawQuery)
})
t.Run("Proxies", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
u := appDetails.SubdomainAppURL(appDetails.OwnerApp)
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), 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)
var appTokenCookie *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == codersdk.DevURLSignedAppTokenCookie {
appTokenCookie = c
break
}
}
require.NotNil(t, appTokenCookie, "no signed token cookie in response")
require.Equal(t, appTokenCookie.Path, "/", "incorrect path on signed token cookie")
// Ensure the session token cookie is valid.
appTokenClient := codersdk.New(appDetails.Client.URL)
appTokenClient.HTTPClient.CheckRedirect = appDetails.Client.HTTPClient.CheckRedirect
appTokenClient.HTTPClient.Transport = appDetails.Client.HTTPClient.Transport
appTokenClient.HTTPClient.Jar, err = cookiejar.New(nil)
require.NoError(t, err)
appTokenClient.HTTPClient.Jar.SetCookies(u, []*http.Cookie{appTokenCookie})
resp, err = requestWithRetries(ctx, t, appTokenClient, http.MethodGet, u.String(), 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, appDetails.Client, http.MethodGet, appDetails.SubdomainAppURL(appDetails.PortApp).String(), 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 := appDetails.Client.Request(ctx, http.MethodGet, appDetails.SubdomainAppURL(appDetails.FakeApp).String(), 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()
app := appDetails.PortApp
app.AppSlugOrPort = strconv.Itoa(codersdk.WorkspaceAgentMinimumListeningPort - 1)
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, appDetails.SubdomainAppURL(app).String(), 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()
appDetails := setupProxyTest(t, &DeploymentOptions{
AppHost: "*-suffix.test.coder.com",
})
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
u := appDetails.SubdomainAppURL(appDetails.OwnerApp)
t.Logf("url: %s", u)
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), 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()
appDetails := setupProxyTest(t, &DeploymentOptions{
AppHost: "*-suffix.test.coder.com",
})
t.Run("NoSuffix", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
u := appDetails.SubdomainAppURL(appDetails.OwnerApp)
// Replace the -suffix with nothing.
u.Host = strings.Replace(u.Host, "-suffix", "", 1)
t.Logf("url: %s", u)
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), 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 := appDetails.SubdomainAppURL(appDetails.OwnerApp)
// Replace the -suffix with something else.
u.Host = strings.Replace(u.Host, "-suffix", "-not-suffix", 1)
t.Logf("url: %s", u)
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), 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("AppSharing", func(t *testing.T) {
t.Parallel()
setup := func(t *testing.T, allowPathAppSharing, allowSiteOwnerAccess bool) (appDetails *AppDetails, 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 = "SomeSecurePassword!"
appDetails = setupProxyTest(t, &DeploymentOptions{
DangerousAllowPathAppSharing: allowPathAppSharing,
DangerousAllowPathAppSiteOwnerAccess: allowSiteOwnerAccess,
// we make the workspace below
noWorkspace: true,
})
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
t.Cleanup(cancel)
// Create a template-admin user in the same org. We don't use an owner
// since they have access to everything.
ownerClient = appDetails.Client
user, err := ownerClient.CreateUser(ctx, codersdk.CreateUserRequest{
Email: "user@coder.com",
Username: "user",
Password: password,
OrganizationID: appDetails.FirstUser.OrganizationID,
})
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.
port := appServer(t)
workspace, agnt = createWorkspaceWithApps(t, client, user.OrganizationIDs[0], user, proxyTestSubdomainRaw, port)
// Verify that the apps have the correct sharing levels set.
workspaceBuild, err := client.WorkspaceBuild(ctx, workspace.LatestBuild.ID)
require.NoError(t, err)
require.NotEmpty(t, workspaceBuild.Resources, "workspace build has no resources")
require.NotEmpty(t, workspaceBuild.Resources[0].Agents, "workspace build has no agents")
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 appDetails, workspace, agnt, user, ownerClient, client, clientInOtherOrg, clientWithNoAuth
}
verifyAccess := func(t *testing.T, appDetails *AppDetails, 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)
app := App{
AppSlugOrPort: appName,
AgentName: agentName,
WorkspaceName: workspaceName,
Username: username,
Query: proxyTestAppQuery,
}
u := appDetails.SubdomainAppURL(app)
if isPathApp {
u = appDetails.PathAppURL(app)
}
res, err := requestWithRetries(ctx, t, client, http.MethodGet, u.String(), nil)
require.NoError(t, err, msg)
dump, err := httputil.DumpResponse(res, true)
_ = res.Body.Close()
require.NoError(t, err, msg)
t.Log(u)
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) {
appDetails, 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.Values.Dangerous.AllowPathAppSharing.Value())
assert.Equal(t, siteOwnerPathAppAccessEnabled, deploymentConfig.Values.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, appDetails, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameOwner, ownerClient, siteOwnerCanAccess, false)
// Owner should be able to access their own workspace.
verifyAccess(t, appDetails, 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, appDetails, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameOwner, clientInOtherOrg, false, false)
// Unauthenticated user should not have any access.
verifyAccess(t, appDetails, 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, appDetails, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameAuthenticated, ownerClient, siteOwnerCanAccessShared, false)
// Owner should be able to access their own workspace.
verifyAccess(t, appDetails, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameAuthenticated, client, true, false)
// Authenticated users should be able to access the workspace.
verifyAccess(t, appDetails, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNameAuthenticated, clientInOtherOrg, allowedUnlessSharingDisabled, false)
// Unauthenticated user should not have any access.
verifyAccess(t, appDetails, 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, appDetails, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNamePublic, ownerClient, siteOwnerCanAccessShared, false)
// Owner should be able to access their own workspace.
verifyAccess(t, appDetails, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNamePublic, client, true, false)
// Authenticated users should be able to access the workspace.
verifyAccess(t, appDetails, isPathApp, user.Username, workspace.Name, agnt.Name, proxyTestAppNamePublic, clientInOtherOrg, allowedUnlessSharingDisabled, false)
// Unauthenticated user should be able to access the workspace.
verifyAccess(t, appDetails, 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)
})
})
t.Run("WorkspaceAppsNonCanonicalHeaders", func(t *testing.T) {
t.Parallel()
// 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)
appDetails := setupProxyTest(t, &DeploymentOptions{
port: uint16(tcpAddr.Port),
})
cases := []struct {
name string
u *url.URL
}{
{
name: "ProxyPath",
u: appDetails.PathAppURL(appDetails.OwnerApp),
},
{
name: "ProxySubdomain",
u: appDetails.SubdomainAppURL(appDetails.OwnerApp),
},
}
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()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.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.SessionTokenHeader, appDetails.Client.SessionToken())
resp, err := doWithRetries(t, appDetails.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"))
})
}
})
}