mirror of https://github.com/coder/coder.git
1716 lines
60 KiB
Go
1716 lines
60 KiB
Go
package apptest
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"path"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-jose/go-jose/v3"
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/rbac"
|
|
"github.com/coder/coder/v2/coderd/workspaceapps"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
// Run runs the entire workspace app test suite against deployments minted
|
|
// by the provided factory.
|
|
//
|
|
// appHostIsPrimary is true if the app host is also the primary coder API
|
|
// server. This disables any tests that test API passthrough or rely on the
|
|
// app server not being the API server.
|
|
// nolint:revive
|
|
func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) {
|
|
setupProxyTest := func(t *testing.T, opts *DeploymentOptions) *Details {
|
|
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.")
|
|
}
|
|
|
|
t.Run("OK", func(t *testing.T) {
|
|
t.Parallel()
|
|
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 := appDetails.AppClient(t)
|
|
testReconnectingPTY(ctx, t, client, appDetails.Agent.ID, "")
|
|
assertWorkspaceLastUsedAtUpdated(t, appDetails)
|
|
})
|
|
|
|
t.Run("SignedTokenQueryParameter", func(t *testing.T) {
|
|
t.Parallel()
|
|
if appHostIsPrimary {
|
|
t.Skip("Tickets are not used for terminal requests on the primary.")
|
|
}
|
|
|
|
appDetails := setupProxyTest(t, nil)
|
|
|
|
u := *appDetails.PathAppBaseURL
|
|
if u.Scheme == "http" {
|
|
u.Scheme = "ws"
|
|
} else {
|
|
u.Scheme = "wss"
|
|
}
|
|
u.Path = fmt.Sprintf("/api/v2/workspaceagents/%s/pty", appDetails.Agent.ID.String())
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
issueRes, err := appDetails.SDKClient.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{
|
|
URL: u.String(),
|
|
AgentID: appDetails.Agent.ID,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Make an unauthenticated client.
|
|
unauthedAppClient := codersdk.New(appDetails.AppClient(t).URL)
|
|
testReconnectingPTY(ctx, t, unauthedAppClient, appDetails.Agent.ID, issueRes.SignedToken)
|
|
assertWorkspaceLastUsedAtUpdated(t, appDetails)
|
|
})
|
|
})
|
|
|
|
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.AppClient(t), http.MethodGet, appDetails.PathAppURL(appDetails.Apps.Owner).String(), nil)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(body), "Path-based applications are disabled")
|
|
// Even though path-based apps are disabled, the request should indicate
|
|
// that the workspace was used.
|
|
assertWorkspaceLastUsedAtNotUpdated(t, appDetails)
|
|
})
|
|
|
|
t.Run("LoginWithoutAuthOnPrimary", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if !appHostIsPrimary {
|
|
t.Skip("This test only applies when testing apps on the primary.")
|
|
}
|
|
|
|
unauthedClient := appDetails.AppClient(t)
|
|
unauthedClient.SetSessionToken("")
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
u := appDetails.PathAppURL(appDetails.Apps.Owner).String()
|
|
resp, err := requestWithRetries(ctx, t, unauthedClient, http.MethodGet, u, nil)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
require.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
|
loc, err := resp.Location()
|
|
require.NoError(t, err)
|
|
require.True(t, loc.Query().Has("message"))
|
|
require.True(t, loc.Query().Has("redirect"))
|
|
assertWorkspaceLastUsedAtUpdated(t, appDetails)
|
|
})
|
|
|
|
t.Run("LoginWithoutAuthOnProxy", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if appHostIsPrimary {
|
|
t.Skip("This test only applies when testing apps on workspace proxies.")
|
|
}
|
|
|
|
unauthedClient := appDetails.AppClient(t)
|
|
unauthedClient.SetSessionToken("")
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
u := appDetails.PathAppURL(appDetails.Apps.Owner)
|
|
resp, err := requestWithRetries(ctx, t, unauthedClient, http.MethodGet, u.String(), nil)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
require.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
|
loc, err := resp.Location()
|
|
require.NoError(t, err)
|
|
require.Equal(t, appDetails.SDKClient.URL.Host, loc.Host)
|
|
require.Equal(t, "/api/v2/applications/auth-redirect", loc.Path)
|
|
|
|
redirectURIStr := loc.Query().Get("redirect_uri")
|
|
require.NotEmpty(t, redirectURIStr)
|
|
redirectURI, err := url.Parse(redirectURIStr)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, u.Scheme, redirectURI.Scheme)
|
|
require.Equal(t, u.Host, redirectURI.Host)
|
|
// TODO(@dean): I have no idea how but the trailing slash on this
|
|
// request is getting stripped.
|
|
require.Equal(t, u.Path, redirectURI.Path+"/")
|
|
require.Equal(t, u.RawQuery, redirectURI.RawQuery)
|
|
assertWorkspaceLastUsedAtUpdated(t, appDetails)
|
|
})
|
|
|
|
t.Run("NoAccessShould404", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
userClient, _ := coderdtest.CreateAnotherUser(t, appDetails.SDKClient, appDetails.FirstUser.OrganizationID, rbac.RoleMember())
|
|
userAppClient := appDetails.AppClient(t)
|
|
userAppClient.SetSessionToken(userClient.SessionToken())
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
resp, err := requestWithRetries(ctx, t, userAppClient, http.MethodGet, appDetails.PathAppURL(appDetails.Apps.Owner).String(), nil)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusNotFound, resp.StatusCode)
|
|
// TODO(cian): A blocked request should not count as workspace usage.
|
|
// assertWorkspaceLastUsedAtNotUpdated(t, appDetails.AppClient(t), appDetails)
|
|
})
|
|
|
|
t.Run("RedirectsWithSlash", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
u := appDetails.PathAppURL(appDetails.Apps.Owner)
|
|
u.Path = strings.TrimSuffix(u.Path, "/")
|
|
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, u.String(), nil)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
|
// TODO(cian): The initial redirect should not count as workspace usage.
|
|
// assertWorkspaceLastUsedAtNotUpdated(t, appDetails.AppClient(t), appDetails)
|
|
})
|
|
|
|
t.Run("RedirectsWithQuery", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
u := appDetails.PathAppURL(appDetails.Apps.Owner)
|
|
u.RawQuery = ""
|
|
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), 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)
|
|
// TODO(cian): The initial redirect should not count as workspace usage.
|
|
// assertWorkspaceLastUsedAtNotUpdated(t, appDetails.AppClient(t), appDetails)
|
|
})
|
|
|
|
t.Run("Proxies", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
u := appDetails.PathAppURL(appDetails.Apps.Owner)
|
|
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), 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.SignedAppTokenCookie {
|
|
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 := appDetails.AppClient(t)
|
|
appTokenClient.SetSessionToken("")
|
|
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)
|
|
assertWorkspaceLastUsedAtUpdated(t, appDetails)
|
|
})
|
|
|
|
t.Run("ProxiesHTTPS", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
appDetails := setupProxyTest(t, &DeploymentOptions{
|
|
ServeHTTPS: true,
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
u := appDetails.PathAppURL(appDetails.Apps.Owner)
|
|
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), 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.SignedAppTokenCookie {
|
|
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 := appDetails.AppClient(t)
|
|
appTokenClient.SetSessionToken("")
|
|
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)
|
|
assertWorkspaceLastUsedAtUpdated(t, appDetails)
|
|
})
|
|
|
|
t.Run("BlocksMe", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
app := appDetails.Apps.Owner
|
|
app.Username = codersdk.Me
|
|
|
|
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), 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")
|
|
// TODO(cian): A blocked request should not count as workspace usage.
|
|
// assertWorkspaceLastUsedAtNotUpdated(t, appDetails.AppClient(t), appDetails)
|
|
})
|
|
|
|
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.AppClient(t), http.MethodGet, appDetails.PathAppURL(appDetails.Apps.Owner).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"))
|
|
assertWorkspaceLastUsedAtUpdated(t, appDetails)
|
|
})
|
|
|
|
t.Run("ProxyError", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
resp, err := appDetails.AppClient(t).Request(ctx, http.MethodGet, appDetails.PathAppURL(appDetails.Apps.Fake).String(), nil)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusBadGateway, resp.StatusCode)
|
|
// An valid authenticated attempt to access a workspace app
|
|
// should count as usage regardless of success.
|
|
assertWorkspaceLastUsedAtUpdated(t, appDetails)
|
|
})
|
|
|
|
t.Run("NoProxyPort", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
resp, err := appDetails.AppClient(t).Request(ctx, http.MethodGet, appDetails.PathAppURL(appDetails.Apps.Port).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)
|
|
assertWorkspaceLastUsedAtUpdated(t, appDetails)
|
|
})
|
|
})
|
|
|
|
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)
|
|
|
|
cases := []struct {
|
|
name string
|
|
appURL *url.URL
|
|
sessionTokenCookieName string
|
|
}{
|
|
{
|
|
name: "Subdomain",
|
|
appURL: appDetails.SubdomainAppURL(appDetails.Apps.Owner),
|
|
sessionTokenCookieName: codersdk.SubdomainAppSessionTokenCookie,
|
|
},
|
|
{
|
|
name: "Path",
|
|
appURL: appDetails.PathAppURL(appDetails.Apps.Owner),
|
|
sessionTokenCookieName: codersdk.PathAppSessionTokenCookie,
|
|
},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
c := c
|
|
|
|
if c.name == "Path" && appHostIsPrimary {
|
|
// Workspace application auth does not apply to path apps
|
|
// served from the primary access URL as no smuggling needs
|
|
// to take place (they're already logged in with a session
|
|
// token).
|
|
continue
|
|
}
|
|
|
|
t.Run(c.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
// Get the current user and API key.
|
|
user, err := appDetails.SDKClient.User(ctx, codersdk.Me)
|
|
require.NoError(t, err)
|
|
currentAPIKey, err := appDetails.SDKClient.APIKeyByID(ctx, appDetails.FirstUser.UserID.String(), strings.Split(appDetails.SDKClient.SessionToken(), "-")[0])
|
|
require.NoError(t, err)
|
|
|
|
appClient := appDetails.AppClient(t)
|
|
appClient.SetSessionToken("")
|
|
|
|
// Try to load the application without authentication.
|
|
u := c.appURL
|
|
u.Path = path.Join(u.Path, "/test")
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
|
require.NoError(t, err)
|
|
|
|
var resp *http.Response
|
|
resp, err = doWithRetries(t, appClient, req)
|
|
require.NoError(t, err)
|
|
|
|
if !assert.Equal(t, http.StatusSeeOther, resp.StatusCode) {
|
|
dump, err := httputil.DumpResponse(resp, true)
|
|
require.NoError(t, err)
|
|
t.Log(string(dump))
|
|
}
|
|
resp.Body.Close()
|
|
|
|
// Check that the Location is correct.
|
|
gotLocation, err := resp.Location()
|
|
require.NoError(t, err)
|
|
// This should always redirect to the primary access URL.
|
|
require.Equal(t, appDetails.SDKClient.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.SDKClient, 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.StatusSeeOther, 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.
|
|
encryptedAPIKey := gotLocation.Query().Get(workspaceapps.SubdomainProxyAPIKeyParam)
|
|
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, appClient, req)
|
|
require.NoError(t, err)
|
|
resp.Body.Close()
|
|
require.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
|
|
|
cookies := resp.Cookies()
|
|
var cookie *http.Cookie
|
|
for _, co := range cookies {
|
|
if co.Name == c.sessionTokenCookieName {
|
|
cookie = co
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, cookie, "no app session token cookie was set")
|
|
apiKey := cookie.Value
|
|
|
|
// Fetch the API key from the API.
|
|
apiKeyInfo, err := appDetails.SDKClient.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
|
|
appTokenAPIClient := codersdk.New(appDetails.SDKClient.URL)
|
|
appTokenAPIClient.SetSessionToken(apiKey)
|
|
appTokenAPIClient.HTTPClient.CheckRedirect = appDetails.SDKClient.HTTPClient.CheckRedirect
|
|
appTokenAPIClient.HTTPClient.Transport = appDetails.SDKClient.HTTPClient.Transport
|
|
|
|
var (
|
|
canCreateApplicationConnect = "can-create-application_connect"
|
|
canReadUserMe = "can-read-user-me"
|
|
)
|
|
authRes, err := appTokenAPIClient.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, appClient, req)
|
|
require.NoError(t, err)
|
|
resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
})
|
|
}
|
|
})
|
|
})
|
|
|
|
t.Run("WorkspaceAppsProxySubdomainHostnamePrefix/OK", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
appDetails := setupProxyTest(t, nil)
|
|
|
|
// Try to load the owner app with a prefix.
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
prefixedOwnerApp := appDetails.Apps.Owner
|
|
prefixedOwnerApp.Prefix = "some---prefix---"
|
|
|
|
u := appDetails.SubdomainAppURL(prefixedOwnerApp)
|
|
require.Contains(t, u.Host, prefixedOwnerApp.Prefix)
|
|
|
|
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, u.String(), nil)
|
|
require.NoError(t, err)
|
|
_ = resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
require.Equal(t, resp.Header.Get("X-Got-Host"), u.Host)
|
|
|
|
// Parse the returned signed token to verify that it contains the
|
|
// prefix.
|
|
var appTokenCookie *http.Cookie
|
|
for _, c := range resp.Cookies() {
|
|
if c.Name == codersdk.SignedAppTokenCookie {
|
|
appTokenCookie = c
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, appTokenCookie, "no signed app token cookie in response")
|
|
|
|
// Parse the JWT without verifying it (since we can't access the key
|
|
// from this test).
|
|
object, err := jose.ParseSigned(appTokenCookie.Value)
|
|
require.NoError(t, err)
|
|
require.Len(t, object.Signatures, 1)
|
|
|
|
// Parse the payload.
|
|
var tok workspaceapps.SignedToken
|
|
//nolint:gosec
|
|
err = json.Unmarshal(object.UnsafePayloadWithoutVerification(), &tok)
|
|
require.NoError(t, err)
|
|
|
|
// Verify the prefix is in the token.
|
|
require.Equal(t, prefixedOwnerApp.Prefix, tok.Request.Prefix)
|
|
|
|
// Ensure the signed app token cookie is valid by making a request with
|
|
// it with no session token.
|
|
appTokenClient := appDetails.AppClient(t)
|
|
appTokenClient.SetSessionToken("")
|
|
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)
|
|
_ = resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
require.Equal(t, resp.Header.Get("X-Got-Host"), u.Host)
|
|
})
|
|
|
|
t.Run("WorkspaceAppsProxySubdomainHostnamePrefix/Different", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
appDetails := setupProxyTest(t, nil)
|
|
|
|
// Try to load the owner app with a prefix.
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
prefixedOwnerApp := appDetails.Apps.Owner
|
|
t.Log(appDetails.SubdomainAppURL(prefixedOwnerApp))
|
|
prefixedOwnerApp.Prefix = "some---prefix---"
|
|
t.Log(appDetails.SubdomainAppURL(prefixedOwnerApp))
|
|
|
|
u := appDetails.SubdomainAppURL(prefixedOwnerApp)
|
|
require.Contains(t, u.Host, prefixedOwnerApp.Prefix)
|
|
|
|
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, u.String(), nil)
|
|
require.NoError(t, err)
|
|
_ = resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
// Find the cookie.
|
|
var appTokenCookie *http.Cookie
|
|
for _, c := range resp.Cookies() {
|
|
if c.Name == codersdk.SignedAppTokenCookie {
|
|
appTokenCookie = c
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, appTokenCookie, "no signed app token cookie in response")
|
|
|
|
// Ensure the signed app token cookie is valid only for the given prefix
|
|
// by making a request with it with no session token.
|
|
appTokenClient := appDetails.AppClient(t)
|
|
appTokenClient.SetSessionToken("")
|
|
appTokenClient.HTTPClient.Jar, err = cookiejar.New(nil)
|
|
require.NoError(t, err)
|
|
appTokenClient.HTTPClient.Jar.SetCookies(u, []*http.Cookie{appTokenCookie})
|
|
|
|
prefixedOwnerApp.Prefix = "different---"
|
|
u = appDetails.SubdomainAppURL(prefixedOwnerApp)
|
|
require.Contains(t, u.Host, prefixedOwnerApp.Prefix)
|
|
|
|
resp, err = requestWithRetries(ctx, t, appTokenClient, http.MethodGet, u.String(), nil)
|
|
require.NoError(t, err)
|
|
_ = resp.Body.Close()
|
|
require.NotEqual(t, http.StatusOK, 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()
|
|
if !appHostIsPrimary {
|
|
t.Skip("app hostname does not serve API")
|
|
}
|
|
// No Hostname set.
|
|
appDetails := setupProxyTest(t, &DeploymentOptions{
|
|
AppHost: "",
|
|
DisableSubdomainApps: true,
|
|
noWorkspace: true,
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
u := *appDetails.SDKClient.URL
|
|
u.Host = "app--agent--workspace--username.test.coder.com"
|
|
u.Path = "/api/v2/users/me"
|
|
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, u.String(), 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.AppClient(t), 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.SDKClient, appDetails.FirstUser.OrganizationID, rbac.RoleMember())
|
|
userAppClient := appDetails.AppClient(t)
|
|
userAppClient.SetSessionToken(userClient.SessionToken())
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
resp, err := requestWithRetries(ctx, t, userAppClient, http.MethodGet, appDetails.SubdomainAppURL(appDetails.Apps.Owner).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.Apps.Owner)
|
|
u.Path = ""
|
|
u.RawQuery = ""
|
|
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), 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.Apps.Owner).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.Apps.Owner)
|
|
u.RawQuery = ""
|
|
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), 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.Apps.Owner).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.Apps.Owner)
|
|
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), 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.SignedAppTokenCookie {
|
|
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 signed app token cookie is valid.
|
|
appTokenClient := appDetails.AppClient(t)
|
|
appTokenClient.SetSessionToken("")
|
|
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("ProxiesHTTPS", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
appDetails := setupProxyTest(t, &DeploymentOptions{
|
|
ServeHTTPS: true,
|
|
})
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
u := appDetails.SubdomainAppURL(appDetails.Apps.Owner)
|
|
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), 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.SignedAppTokenCookie {
|
|
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 signed app token cookie is valid.
|
|
appTokenClient := appDetails.AppClient(t)
|
|
appTokenClient.SetSessionToken("")
|
|
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.AppClient(t), http.MethodGet, appDetails.SubdomainAppURL(appDetails.Apps.Port).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("PortSharingNoShare", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
userClient, _ := coderdtest.CreateAnotherUser(t, appDetails.SDKClient, appDetails.FirstUser.OrganizationID, rbac.RoleMember())
|
|
userAppClient := appDetails.AppClient(t)
|
|
userAppClient.SetSessionToken(userClient.SessionToken())
|
|
|
|
resp, err := requestWithRetries(ctx, t, userAppClient, http.MethodGet, appDetails.SubdomainAppURL(appDetails.Apps.Port).String(), nil)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusNotFound, resp.StatusCode)
|
|
})
|
|
|
|
t.Run("PortSharingAuthenticatedOK", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
// we are shadowing the parent since we are changing the state
|
|
appDetails := setupProxyTest(t, nil)
|
|
|
|
port, err := strconv.ParseInt(appDetails.Apps.Port.AppSlugOrPort, 10, 32)
|
|
require.NoError(t, err)
|
|
// set the port we have to be shared with authenticated users
|
|
_, err = appDetails.SDKClient.UpsertWorkspaceAgentPortShare(ctx, appDetails.Workspace.ID, codersdk.UpsertWorkspaceAgentPortShareRequest{
|
|
AgentName: proxyTestAgentName,
|
|
Port: int32(port),
|
|
ShareLevel: codersdk.WorkspaceAgentPortShareLevelAuthenticated,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
userClient, _ := coderdtest.CreateAnotherUser(t, appDetails.SDKClient, appDetails.FirstUser.OrganizationID, rbac.RoleMember())
|
|
userAppClient := appDetails.AppClient(t)
|
|
userAppClient.SetSessionToken(userClient.SessionToken())
|
|
|
|
resp, err := requestWithRetries(ctx, t, userAppClient, http.MethodGet, appDetails.SubdomainAppURL(appDetails.Apps.Port).String(), nil)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
})
|
|
|
|
t.Run("PortSharingPublicOK", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
// we are shadowing the parent since we are changing the state
|
|
appDetails := setupProxyTest(t, nil)
|
|
|
|
port, err := strconv.ParseInt(appDetails.Apps.Port.AppSlugOrPort, 10, 32)
|
|
require.NoError(t, err)
|
|
// set the port we have to be shared with public
|
|
_, err = appDetails.SDKClient.UpsertWorkspaceAgentPortShare(ctx, appDetails.Workspace.ID, codersdk.UpsertWorkspaceAgentPortShareRequest{
|
|
AgentName: proxyTestAgentName,
|
|
Port: int32(port),
|
|
ShareLevel: codersdk.WorkspaceAgentPortShareLevelPublic,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
publicAppClient := appDetails.AppClient(t)
|
|
publicAppClient.SetSessionToken("")
|
|
|
|
resp, err := requestWithRetries(ctx, t, publicAppClient, http.MethodGet, appDetails.SubdomainAppURL(appDetails.Apps.Port).String(), nil)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
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.AppClient(t).Request(ctx, http.MethodGet, appDetails.SubdomainAppURL(appDetails.Apps.Fake).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.Apps.Port
|
|
app.AppSlugOrPort = strconv.Itoa(codersdk.WorkspaceAgentMinimumListeningPort - 1)
|
|
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), 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.Apps.Owner)
|
|
t.Logf("url: %s", u)
|
|
|
|
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), 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("WildcardPortOK", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Manually specifying a port should override the access url port on
|
|
// the app host.
|
|
appDetails := setupProxyTest(t, &DeploymentOptions{
|
|
// Just throw both the wsproxy and primary to same url.
|
|
AppHost: "*.test.coder.com:4444",
|
|
PrimaryAppHost: "*.test.coder.com:4444",
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
u := appDetails.SubdomainAppURL(appDetails.Apps.Owner)
|
|
t.Logf("url: %s", u)
|
|
require.Equal(t, "4444", u.Port(), "port should be 4444")
|
|
|
|
// Assert the api response the UI uses has the port.
|
|
apphost, err := appDetails.SDKClient.AppHost(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "*.test.coder.com:4444", apphost.Host, "apphost has port")
|
|
|
|
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), 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.Apps.Owner)
|
|
// Replace the -suffix with nothing.
|
|
u.Host = strings.Replace(u.Host, "-suffix", "", 1)
|
|
t.Logf("url: %s", u)
|
|
|
|
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), 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 or a 404 page, 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.Apps.Owner)
|
|
// 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.AppClient(t), 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 *Details, 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.SDKClient
|
|
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, nil, false)
|
|
workspace, _ = createWorkspaceWithApps(t, client, user.OrganizationIDs[0], user, port, false)
|
|
|
|
// 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 *Details, 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.
|
|
sessionTokens := []string{client.SessionToken()}
|
|
if client.SessionToken() != "" {
|
|
token, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
|
Scope: codersdk.APIKeyScopeApplicationConnect,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
sessionTokens = append(sessionTokens, token.Key)
|
|
}
|
|
|
|
for i, sessionToken := range sessionTokens {
|
|
msg := fmt.Sprintf("client %d", i)
|
|
|
|
app := App{
|
|
Username: username,
|
|
WorkspaceName: workspaceName,
|
|
AgentName: agentName,
|
|
AppSlugOrPort: appName,
|
|
Query: proxyTestAppQuery,
|
|
}
|
|
u := appDetails.SubdomainAppURL(app)
|
|
if isPathApp {
|
|
u = appDetails.PathAppURL(app)
|
|
}
|
|
|
|
client := appDetails.AppClient(t)
|
|
client.SetSessionToken(sessionToken)
|
|
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.StatusSeeOther, res.StatusCode, "should not have access, expected See Other redirect. "+msg)
|
|
location, err := res.Location()
|
|
require.NoError(t, err, msg)
|
|
|
|
expectedPath := "/login"
|
|
if !isPathApp || !appHostIsPrimary {
|
|
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.Apps.Owner),
|
|
},
|
|
{
|
|
name: "ProxySubdomain",
|
|
u: appDetails.SubdomainAppURL(appDetails.Apps.Owner),
|
|
},
|
|
}
|
|
|
|
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.SDKClient.SessionToken())
|
|
|
|
resp, err := doWithRetries(t, appDetails.AppClient(t), 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("CORSHeadersStripped", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
appDetails := setupProxyTest(t, &DeploymentOptions{
|
|
headers: http.Header{
|
|
"X-Foobar": []string{"baz"},
|
|
"Access-Control-Allow-Origin": []string{"http://localhost"},
|
|
"access-control-allow-origin": []string{"http://localhost"},
|
|
"Access-Control-Allow-Credentials": []string{"true"},
|
|
"Access-Control-Allow-Methods": []string{"PUT"},
|
|
"Access-Control-Allow-Headers": []string{"X-Foobar"},
|
|
"Vary": []string{
|
|
"Origin",
|
|
"origin",
|
|
"Access-Control-Request-Headers",
|
|
"access-Control-request-Headers",
|
|
"Access-Control-Request-Methods",
|
|
"ACCESS-CONTROL-REQUEST-METHODS",
|
|
"X-Foobar",
|
|
},
|
|
},
|
|
})
|
|
|
|
appURL := appDetails.SubdomainAppURL(appDetails.Apps.Owner)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, appURL.String(), nil)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
require.Equal(t, []string(nil), resp.Header.Values("Access-Control-Allow-Origin"))
|
|
require.Equal(t, []string(nil), resp.Header.Values("Access-Control-Allow-Credentials"))
|
|
require.Equal(t, []string(nil), resp.Header.Values("Access-Control-Allow-Methods"))
|
|
require.Equal(t, []string(nil), resp.Header.Values("Access-Control-Allow-Headers"))
|
|
// Somehow there are two "Origin"s in Vary even though there should only be
|
|
// one (from the CORS middleware), even if you remove the headers being sent
|
|
// above. When I do nothing else but change the expected value below to
|
|
// have two "Origin"s suddenly Vary only has one. It is somehow always the
|
|
// opposite of whatever I put for the expected. So, reluctantly, remove
|
|
// duplicate "Origin" values.
|
|
var deduped []string
|
|
var addedOrigin bool
|
|
for _, value := range resp.Header.Values("Vary") {
|
|
if value != "Origin" || !addedOrigin {
|
|
if value == "Origin" {
|
|
addedOrigin = true
|
|
}
|
|
deduped = append(deduped, value)
|
|
}
|
|
}
|
|
require.Equal(t, []string{"Origin", "X-Foobar"}, deduped)
|
|
require.Equal(t, []string{"baz"}, resp.Header.Values("X-Foobar"))
|
|
})
|
|
|
|
t.Run("ReportStats", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
reporter := &fakeStatsReporter{}
|
|
appDetails := setupProxyTest(t, &DeploymentOptions{
|
|
StatsCollectorOptions: workspaceapps.StatsCollectorOptions{
|
|
Reporter: reporter,
|
|
ReportInterval: time.Hour,
|
|
RollupWindow: time.Minute,
|
|
},
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
u := appDetails.PathAppURL(appDetails.Apps.Owner)
|
|
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, u.String(), nil)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
_, err = io.Copy(io.Discard, resp.Body)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var stats []workspaceapps.StatsReport
|
|
require.Eventually(t, func() bool {
|
|
// Keep flushing until we get a non-empty stats report.
|
|
appDetails.FlushStats()
|
|
stats = reporter.stats()
|
|
return len(stats) > 0
|
|
}, testutil.WaitLong, testutil.IntervalFast, "stats not reported")
|
|
|
|
assert.Equal(t, workspaceapps.AccessMethodPath, stats[0].AccessMethod)
|
|
assert.Equal(t, "test-app-owner", stats[0].SlugOrPort)
|
|
assert.Equal(t, 1, stats[0].Requests)
|
|
})
|
|
|
|
t.Run("WorkspaceOffline", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
appDetails := setupProxyTest(t, nil)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
_ = coderdtest.MustTransitionWorkspace(t, appDetails.SDKClient, appDetails.Workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
|
|
|
|
u := appDetails.PathAppURL(appDetails.Apps.Owner)
|
|
resp, err := appDetails.AppClient(t).Request(ctx, http.MethodGet, u.String(), nil)
|
|
require.NoError(t, err)
|
|
_ = resp.Body.Close()
|
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
require.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type"))
|
|
})
|
|
}
|
|
|
|
type fakeStatsReporter struct {
|
|
mu sync.Mutex
|
|
s []workspaceapps.StatsReport
|
|
}
|
|
|
|
func (r *fakeStatsReporter) stats() []workspaceapps.StatsReport {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.s
|
|
}
|
|
|
|
func (r *fakeStatsReporter) Report(_ context.Context, stats []workspaceapps.StatsReport) error {
|
|
r.mu.Lock()
|
|
r.s = append(r.s, stats...)
|
|
r.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func testReconnectingPTY(ctx context.Context, t *testing.T, client *codersdk.Client, agentID uuid.UUID, signedToken string) {
|
|
opts := codersdk.WorkspaceAgentReconnectingPTYOpts{
|
|
AgentID: agentID,
|
|
Reconnect: uuid.New(),
|
|
Width: 80,
|
|
Height: 80,
|
|
// --norc disables executing .bashrc, which is often used to customize the bash prompt
|
|
Command: "bash --norc",
|
|
SignedToken: signedToken,
|
|
}
|
|
matchPrompt := func(line string) bool {
|
|
return strings.Contains(line, "$ ") || strings.Contains(line, "# ")
|
|
}
|
|
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")
|
|
}
|
|
matchExitCommand := func(line string) bool {
|
|
return strings.Contains(line, "exit")
|
|
}
|
|
matchExitOutput := func(line string) bool {
|
|
return strings.Contains(line, "exit") || strings.Contains(line, "logout")
|
|
}
|
|
|
|
conn, err := client.WorkspaceAgentReconnectingPTY(ctx, opts)
|
|
require.NoError(t, err)
|
|
defer conn.Close()
|
|
|
|
tr := testutil.NewTerminalReader(t, conn)
|
|
// Wait for the prompt before writing commands. If the command arrives before the prompt is written, screen
|
|
// will sometimes put the command output on the same line as the command and the test will flake
|
|
require.NoError(t, tr.ReadUntil(ctx, matchPrompt), "find prompt")
|
|
|
|
data, err := json.Marshal(codersdk.ReconnectingPTYRequest{
|
|
Data: "echo test\r",
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = conn.Write(data)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, tr.ReadUntil(ctx, matchEchoCommand), "find echo command")
|
|
require.NoError(t, tr.ReadUntil(ctx, matchEchoOutput), "find echo output")
|
|
|
|
// Exit should cause the connection to close.
|
|
data, err = json.Marshal(codersdk.ReconnectingPTYRequest{
|
|
Data: "exit\r",
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = conn.Write(data)
|
|
require.NoError(t, err)
|
|
|
|
// Once for the input and again for the output.
|
|
require.NoError(t, tr.ReadUntil(ctx, matchExitCommand), "find exit command")
|
|
require.NoError(t, tr.ReadUntil(ctx, matchExitOutput), "find exit output")
|
|
|
|
// Ensure the connection closes.
|
|
require.ErrorIs(t, tr.ReadUntil(ctx, nil), io.EOF)
|
|
}
|
|
|
|
// Accessing an app should update the workspace's LastUsedAt.
|
|
// NOTE: Despite our efforts with the flush channel, this is inherently racy.
|
|
func assertWorkspaceLastUsedAtUpdated(t testing.TB, details *Details) {
|
|
t.Helper()
|
|
|
|
// Wait for stats to fully flush.
|
|
require.Eventually(t, func() bool {
|
|
details.FlushStats()
|
|
ws, err := details.SDKClient.Workspace(context.Background(), details.Workspace.ID)
|
|
assert.NoError(t, err)
|
|
return ws.LastUsedAt.After(details.Workspace.LastUsedAt)
|
|
}, testutil.WaitShort, testutil.IntervalMedium, "workspace LastUsedAt not updated when it should have been")
|
|
}
|
|
|
|
// Except when it sometimes shouldn't (e.g. no access)
|
|
// NOTE: Despite our efforts with the flush channel, this is inherently racy.
|
|
func assertWorkspaceLastUsedAtNotUpdated(t testing.TB, details *Details) {
|
|
t.Helper()
|
|
|
|
details.FlushStats()
|
|
ws, err := details.SDKClient.Workspace(context.Background(), details.Workspace.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, ws.LastUsedAt, details.Workspace.LastUsedAt, "workspace LastUsedAt updated when it should not have been")
|
|
}
|