mirror of
https://github.com/coder/coder.git
synced 2026-06-03 04:58:23 +00:00
81468323e0
`time.Now()` has nanosecond precision while Postgres timestamps are microsecond precision. When tests compare `time.Now()` against DB-sourced timestamps using `Before`/`After`/`WithinRange`/etc., there is a non-zero flake risk from the precision mismatch. This replaces `time.Now()` with `dbtime.Now()` (which rounds to microsecond precision) in all test assertions that compare against database timestamps. Follows from #22684. ## Changes (11 files) | File | Changes | |---|---| | `coderd/apikey_test.go` | 11 comparisons with `ExpiresAt` | | `coderd/users_test.go` | 2 comparisons with `ExpiresAt` | | `coderd/oauth2_test.go` | 1 comparison with `token.Expiry` | | `coderd/workspaces_test.go` | 2 comparisons with `DormantAt` | | `coderd/workspaceagents_test.go` | 3 comparisons with `ConnectedAt`/`DisconnectedAt` | | `coderd/workspaceapps/db_test.go` | 1 comparison with `token.Expiry` | | `coderd/provisionerdserver/provisionerdserver_test.go` | 1 comparison with `key.ExpiresAt` | | `enterprise/coderd/workspaces_test.go` | 1 comparison with `DormantAt` | | `enterprise/coderd/license/license_test.go` | 3 `NotBefore` values | | `enterprise/coderd/licenses_test.go` | 2 `NotBefore` values | | `enterprise/coderd/users_test.go` | 3 `Next()` comparisons | ## Not changed (intentionally) - `scaletest/placebo/run_test.go` — compares wall-clock elapsed time, not DB timestamps - `cli/server_test.go`, `coderd/jwtutils/jwt_test.go`, `enterprise/aibridgeproxyd/aibridgeproxyd_test.go` — TLS cert fields, not DB-stored - `coderd/azureidentity/azureidentity_test.go` — Azure cert expiry, not DB 🤖 Generated by Claude Opus 4.6 but reviewed manually.
1297 lines
43 KiB
Go
1297 lines
43 KiB
Go
package workspaceapps_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-jose/go-jose/v4/jwt"
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/agent/agenttest"
|
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
|
"github.com/coder/coder/v2/coderd/connectionlog"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbtime"
|
|
"github.com/coder/coder/v2/coderd/httpmw"
|
|
"github.com/coder/coder/v2/coderd/jwtutils"
|
|
"github.com/coder/coder/v2/coderd/tracing"
|
|
"github.com/coder/coder/v2/coderd/workspaceapps"
|
|
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/provisioner/echo"
|
|
"github.com/coder/coder/v2/provisionersdk/proto"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
func Test_ResolveRequest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
agentName = "agent"
|
|
appNameOwner = "app-owner"
|
|
appNameAuthed = "app-authed"
|
|
appNamePublic = "app-public"
|
|
appNameInvalidURL = "app-invalid-url"
|
|
// Users can access unhealthy and initializing apps (as of 2024-02).
|
|
appNameUnhealthy = "app-unhealthy"
|
|
appNameInitializing = "app-initializing"
|
|
appNameEndsInS = "app-ends-in-s"
|
|
|
|
// This agent will never connect, so it will never become "connected".
|
|
// Users cannot access unhealthy agents.
|
|
agentNameUnhealthy = "agent-unhealthy"
|
|
appNameAgentUnhealthy = "app-agent-unhealthy"
|
|
|
|
// This is not a valid URL we listen on in the test, but it needs to be
|
|
// set to a value.
|
|
appURL = "http://localhost:8080"
|
|
)
|
|
allApps := []string{appNameOwner, appNameAuthed, appNamePublic}
|
|
|
|
// Start a listener for a server that always responds with 500 for the
|
|
// unhealthy app.
|
|
unhealthySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte("unhealthy"))
|
|
}))
|
|
t.Cleanup(unhealthySrv.Close)
|
|
|
|
// Start a listener for a server that never responds.
|
|
initializingServer, err := net.Listen("tcp", "localhost:0")
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
_ = initializingServer.Close()
|
|
})
|
|
initializingURL := fmt.Sprintf("http://%s", initializingServer.Addr().String())
|
|
|
|
deploymentValues := coderdtest.DeploymentValues(t)
|
|
deploymentValues.DisablePathApps = false
|
|
deploymentValues.Dangerous.AllowPathAppSharing = true
|
|
deploymentValues.Dangerous.AllowPathAppSiteOwnerAccess = true
|
|
|
|
connLogger := connectionlog.NewFake()
|
|
t.Cleanup(func() {
|
|
if t.Failed() {
|
|
return
|
|
}
|
|
assert.Len(t, connLogger.ConnectionLogs(), 0, "one or more test cases produced unexpected connection logs, did you replace the auditor or forget to call ResetLogs?")
|
|
})
|
|
client, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
|
AppHostname: "*.test.coder.com",
|
|
DeploymentValues: deploymentValues,
|
|
IncludeProvisionerDaemon: true,
|
|
AgentStatsRefreshInterval: time.Millisecond * 100,
|
|
MetricsCacheRefreshInterval: time.Millisecond * 100,
|
|
RealIPConfig: &httpmw.RealIPConfig{
|
|
TrustedOrigins: []*net.IPNet{{
|
|
IP: net.ParseIP("127.0.0.1"),
|
|
Mask: net.CIDRMask(8, 32),
|
|
}},
|
|
TrustedHeaders: []string{
|
|
"CF-Connecting-IP",
|
|
},
|
|
},
|
|
ConnectionLogger: connLogger,
|
|
})
|
|
t.Cleanup(func() {
|
|
_ = closer.Close()
|
|
})
|
|
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
me, err := client.User(ctx, codersdk.Me)
|
|
require.NoError(t, err)
|
|
|
|
secondUserClient, secondUser := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
|
|
|
|
agentAuthToken := uuid.NewString()
|
|
version := coderdtest.CreateTemplateVersion(t, client, firstUser.OrganizationID, &echo.Responses{
|
|
Parse: echo.ParseComplete,
|
|
ProvisionPlan: echo.PlanComplete,
|
|
ProvisionGraph: []*proto.Response{{
|
|
Type: &proto.Response_Graph{
|
|
Graph: &proto.GraphComplete{
|
|
Resources: []*proto.Resource{{
|
|
Name: "example",
|
|
Type: "aws_instance",
|
|
Agents: []*proto.Agent{
|
|
{
|
|
Id: uuid.NewString(),
|
|
Name: agentName,
|
|
Auth: &proto.Agent_Token{
|
|
Token: agentAuthToken,
|
|
},
|
|
Apps: []*proto.App{
|
|
{
|
|
Slug: appNameOwner,
|
|
DisplayName: appNameOwner,
|
|
SharingLevel: proto.AppSharingLevel_OWNER,
|
|
Url: appURL,
|
|
},
|
|
{
|
|
Slug: appNameAuthed,
|
|
DisplayName: appNameAuthed,
|
|
SharingLevel: proto.AppSharingLevel_AUTHENTICATED,
|
|
Url: appURL,
|
|
},
|
|
{
|
|
Slug: appNamePublic,
|
|
DisplayName: appNamePublic,
|
|
SharingLevel: proto.AppSharingLevel_PUBLIC,
|
|
Url: appURL,
|
|
},
|
|
{
|
|
Slug: appNameInvalidURL,
|
|
DisplayName: appNameInvalidURL,
|
|
SharingLevel: proto.AppSharingLevel_PUBLIC,
|
|
Url: "test:path/to/app",
|
|
},
|
|
{
|
|
Slug: appNameUnhealthy,
|
|
DisplayName: appNameUnhealthy,
|
|
SharingLevel: proto.AppSharingLevel_PUBLIC,
|
|
Url: appURL,
|
|
Healthcheck: &proto.Healthcheck{
|
|
Url: unhealthySrv.URL,
|
|
Interval: 1,
|
|
Threshold: 1,
|
|
},
|
|
},
|
|
{
|
|
Slug: appNameInitializing,
|
|
DisplayName: appNameInitializing,
|
|
SharingLevel: proto.AppSharingLevel_PUBLIC,
|
|
Url: appURL,
|
|
Healthcheck: &proto.Healthcheck{
|
|
Url: initializingURL,
|
|
Interval: 30,
|
|
Threshold: 1000,
|
|
},
|
|
},
|
|
{
|
|
Slug: appNameEndsInS,
|
|
DisplayName: appNameEndsInS,
|
|
SharingLevel: proto.AppSharingLevel_OWNER,
|
|
Url: appURL,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Id: uuid.NewString(),
|
|
Name: agentNameUnhealthy,
|
|
Auth: &proto.Agent_Token{
|
|
Token: uuid.NewString(),
|
|
},
|
|
Apps: []*proto.App{
|
|
{
|
|
Slug: appNameAgentUnhealthy,
|
|
DisplayName: appNameAgentUnhealthy,
|
|
SharingLevel: proto.AppSharingLevel_PUBLIC,
|
|
Url: appURL,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}},
|
|
},
|
|
},
|
|
}},
|
|
})
|
|
template := coderdtest.CreateTemplate(t, client, firstUser.OrganizationID, version.ID)
|
|
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
|
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
|
|
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
|
|
|
_ = agenttest.New(t, client.URL, agentAuthToken)
|
|
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID, agentName)
|
|
|
|
agentID := uuid.Nil
|
|
for _, resource := range resources {
|
|
for _, agnt := range resource.Agents {
|
|
if agnt.Name == agentName {
|
|
agentID = agnt.ID
|
|
break
|
|
}
|
|
}
|
|
}
|
|
require.NotEqual(t, uuid.Nil, agentID)
|
|
|
|
// Reset audit logs so cleanup check can pass.
|
|
connLogger.Reset()
|
|
|
|
t.Run("OK", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
workspaceNameOrID string
|
|
agentNameOrID string
|
|
}{
|
|
{
|
|
name: "Names",
|
|
workspaceNameOrID: workspace.Name,
|
|
agentNameOrID: agentName,
|
|
},
|
|
{
|
|
name: "IDs",
|
|
workspaceNameOrID: workspace.ID.String(),
|
|
agentNameOrID: agentID.String(),
|
|
},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
// Try resolving a request for each app as the owner, without a
|
|
// token, then use the token to resolve each app.
|
|
for _, app := range allApps {
|
|
req := (workspaceapps.Request{
|
|
AccessMethod: workspaceapps.AccessMethodPath,
|
|
BasePath: "/app",
|
|
UsernameOrID: me.Username,
|
|
WorkspaceNameOrID: c.workspaceNameOrID,
|
|
AgentNameOrID: c.agentNameOrID,
|
|
AppSlugOrPort: app,
|
|
}).Normalize()
|
|
|
|
connLogger := connectionlog.NewFake()
|
|
auditableIP := testutil.RandomIPv6(t)
|
|
auditableUA := "Noitcennoc"
|
|
|
|
t.Log("app", app)
|
|
rw := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/app", nil)
|
|
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
|
r.RemoteAddr = auditableIP
|
|
r.Header.Set("User-Agent", auditableUA)
|
|
|
|
// Try resolving the request without a token.
|
|
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
|
|
Logger: api.Logger,
|
|
SignedTokenProvider: api.WorkspaceAppsProvider,
|
|
DashboardURL: api.AccessURL,
|
|
PathAppBaseURL: api.AccessURL,
|
|
AppHostname: api.AppHostname,
|
|
AppRequest: req,
|
|
})
|
|
w := rw.Result()
|
|
if !assert.True(t, ok) {
|
|
dump, err := httputil.DumpResponse(w, true)
|
|
require.NoError(t, err, "error dumping failed response")
|
|
t.Log(string(dump))
|
|
return
|
|
}
|
|
_ = w.Body.Close()
|
|
|
|
require.Equal(t, &workspaceapps.SignedToken{
|
|
RegisteredClaims: jwtutils.RegisteredClaims{
|
|
Expiry: jwt.NewNumericDate(token.Expiry.Time()),
|
|
},
|
|
Request: req,
|
|
UserID: me.ID,
|
|
WorkspaceID: workspace.ID,
|
|
AgentID: agentID,
|
|
AppURL: appURL,
|
|
CORSBehavior: codersdk.CORSBehaviorSimple,
|
|
}, token)
|
|
require.NotZero(t, token.Expiry)
|
|
require.WithinDuration(t, dbtime.Now().Add(workspaceapps.DefaultTokenExpiry), token.Expiry.Time(), time.Minute)
|
|
|
|
// Check that the token was set in the response and is valid.
|
|
require.Len(t, w.Cookies(), 1)
|
|
cookie := w.Cookies()[0]
|
|
require.Equal(t, codersdk.SignedAppTokenCookie, cookie.Name)
|
|
require.Equal(t, req.BasePath, cookie.Path)
|
|
|
|
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, database.ConnectionTypeWorkspaceApp, me.ID)
|
|
require.Len(t, connLogger.ConnectionLogs(), 1)
|
|
|
|
var parsedToken workspaceapps.SignedToken
|
|
err := jwtutils.Verify(ctx, api.AppSigningKeyCache, cookie.Value, &parsedToken)
|
|
require.NoError(t, err)
|
|
// normalize expiry
|
|
require.WithinDuration(t, token.Expiry.Time(), parsedToken.Expiry.Time(), 2*time.Second)
|
|
parsedToken.Expiry = token.Expiry
|
|
require.Equal(t, token, &parsedToken)
|
|
|
|
// Try resolving the request with the token only.
|
|
rw = httptest.NewRecorder()
|
|
r = httptest.NewRequest("GET", "/app", nil)
|
|
r.AddCookie(cookie)
|
|
r.RemoteAddr = auditableIP
|
|
|
|
secondToken, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
|
|
Logger: api.Logger,
|
|
SignedTokenProvider: api.WorkspaceAppsProvider,
|
|
DashboardURL: api.AccessURL,
|
|
PathAppBaseURL: api.AccessURL,
|
|
AppHostname: api.AppHostname,
|
|
AppRequest: req,
|
|
})
|
|
require.True(t, ok)
|
|
// normalize expiry
|
|
require.WithinDuration(t, token.Expiry.Time(), secondToken.Expiry.Time(), 2*time.Second)
|
|
secondToken.Expiry = token.Expiry
|
|
require.Equal(t, token, secondToken)
|
|
require.Len(t, connLogger.ConnectionLogs(), 1, "no new connection log, FromRequest returned the same token and is not logged")
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("AuthenticatedOtherUser", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, app := range allApps {
|
|
req := (workspaceapps.Request{
|
|
AccessMethod: workspaceapps.AccessMethodPath,
|
|
BasePath: "/app",
|
|
UsernameOrID: me.Username,
|
|
WorkspaceNameOrID: workspace.Name,
|
|
AgentNameOrID: agentName,
|
|
AppSlugOrPort: app,
|
|
}).Normalize()
|
|
|
|
connLogger := connectionlog.NewFake()
|
|
auditableIP := testutil.RandomIPv6(t)
|
|
|
|
t.Log("app", app)
|
|
rw := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/app", nil)
|
|
r.Header.Set(codersdk.SessionTokenHeader, secondUserClient.SessionToken())
|
|
r.RemoteAddr = auditableIP
|
|
|
|
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
|
|
Logger: api.Logger,
|
|
SignedTokenProvider: api.WorkspaceAppsProvider,
|
|
DashboardURL: api.AccessURL,
|
|
PathAppBaseURL: api.AccessURL,
|
|
AppHostname: api.AppHostname,
|
|
AppRequest: req,
|
|
})
|
|
w := rw.Result()
|
|
_ = w.Body.Close()
|
|
if app == appNameOwner {
|
|
require.False(t, ok)
|
|
require.Nil(t, token)
|
|
require.NotZero(t, w.StatusCode)
|
|
require.Equal(t, http.StatusNotFound, w.StatusCode)
|
|
require.Len(t, connLogger.ConnectionLogs(), 1)
|
|
return
|
|
}
|
|
require.True(t, ok)
|
|
require.NotNil(t, token)
|
|
require.Zero(t, w.StatusCode)
|
|
|
|
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, database.ConnectionTypeWorkspaceApp, secondUser.ID)
|
|
require.Len(t, connLogger.ConnectionLogs(), 1)
|
|
}
|
|
})
|
|
|
|
t.Run("Unauthenticated", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, app := range allApps {
|
|
req := (workspaceapps.Request{
|
|
AccessMethod: workspaceapps.AccessMethodPath,
|
|
BasePath: "/app",
|
|
UsernameOrID: me.Username,
|
|
WorkspaceNameOrID: workspace.Name,
|
|
AgentNameOrID: agentName,
|
|
AppSlugOrPort: app,
|
|
}).Normalize()
|
|
|
|
connLogger := connectionlog.NewFake()
|
|
auditableIP := testutil.RandomIPv6(t)
|
|
|
|
t.Log("app", app)
|
|
rw := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/app", nil)
|
|
r.RemoteAddr = auditableIP
|
|
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
|
|
Logger: api.Logger,
|
|
SignedTokenProvider: api.WorkspaceAppsProvider,
|
|
DashboardURL: api.AccessURL,
|
|
PathAppBaseURL: api.AccessURL,
|
|
AppHostname: api.AppHostname,
|
|
AppRequest: req,
|
|
})
|
|
w := rw.Result()
|
|
if app != appNamePublic {
|
|
require.False(t, ok)
|
|
require.Nil(t, token)
|
|
require.NotZero(t, rw.Code)
|
|
require.NotEqual(t, http.StatusOK, rw.Code)
|
|
|
|
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, database.ConnectionTypeWorkspaceApp, uuid.Nil)
|
|
require.Len(t, connLogger.ConnectionLogs(), 1)
|
|
} else {
|
|
if !assert.True(t, ok) {
|
|
dump, err := httputil.DumpResponse(w, true)
|
|
require.NoError(t, err, "error dumping failed response")
|
|
t.Log(string(dump))
|
|
return
|
|
}
|
|
require.NotNil(t, token)
|
|
if rw.Code != 0 && rw.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 (or unset) response code, got %d", rw.Code)
|
|
}
|
|
|
|
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, database.ConnectionTypeWorkspaceApp, uuid.Nil)
|
|
require.Len(t, connLogger.ConnectionLogs(), 1)
|
|
}
|
|
_ = w.Body.Close()
|
|
}
|
|
})
|
|
|
|
t.Run("Invalid", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
req := (workspaceapps.Request{
|
|
AccessMethod: "invalid",
|
|
}).Normalize()
|
|
connLogger := connectionlog.NewFake()
|
|
auditableIP := testutil.RandomIPv6(t)
|
|
rw := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/app", nil)
|
|
r.RemoteAddr = auditableIP
|
|
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
|
|
Logger: api.Logger,
|
|
SignedTokenProvider: api.WorkspaceAppsProvider,
|
|
DashboardURL: api.AccessURL,
|
|
PathAppBaseURL: api.AccessURL,
|
|
AppHostname: api.AppHostname,
|
|
AppRequest: req,
|
|
})
|
|
require.False(t, ok)
|
|
require.Nil(t, token)
|
|
require.Len(t, connLogger.ConnectionLogs(), 0)
|
|
})
|
|
|
|
t.Run("SplitWorkspaceAndAgent", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
workspaceAndAgent string
|
|
workspace string
|
|
agent string
|
|
ok bool
|
|
}{
|
|
{
|
|
name: "WorkspaceOnly",
|
|
workspaceAndAgent: workspace.Name,
|
|
workspace: workspace.Name,
|
|
agent: "",
|
|
ok: true,
|
|
},
|
|
{
|
|
name: "WorkspaceAndAgent",
|
|
workspaceAndAgent: fmt.Sprintf("%s.%s", workspace.Name, agentName),
|
|
workspace: workspace.Name,
|
|
agent: agentName,
|
|
ok: true,
|
|
},
|
|
{
|
|
name: "WorkspaceID",
|
|
workspaceAndAgent: workspace.ID.String(),
|
|
workspace: workspace.ID.String(),
|
|
agent: "",
|
|
ok: true,
|
|
},
|
|
{
|
|
name: "WorkspaceIDAndAgentID",
|
|
workspaceAndAgent: fmt.Sprintf("%s.%s", workspace.ID, agentID),
|
|
workspace: workspace.ID.String(),
|
|
agent: agentID.String(),
|
|
ok: true,
|
|
},
|
|
{
|
|
name: "Invalid1",
|
|
workspaceAndAgent: "invalid",
|
|
ok: false,
|
|
},
|
|
{
|
|
name: "Invalid2",
|
|
workspaceAndAgent: ".",
|
|
ok: false,
|
|
},
|
|
{
|
|
name: "Slash",
|
|
workspaceAndAgent: fmt.Sprintf("%s/%s", workspace.Name, agentName),
|
|
ok: false,
|
|
},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
req := (workspaceapps.Request{
|
|
AccessMethod: workspaceapps.AccessMethodPath,
|
|
BasePath: "/app",
|
|
UsernameOrID: me.Username,
|
|
WorkspaceAndAgent: c.workspaceAndAgent,
|
|
AppSlugOrPort: appNamePublic,
|
|
}).Normalize()
|
|
|
|
connLogger := connectionlog.NewFake()
|
|
auditableIP := testutil.RandomIPv6(t)
|
|
|
|
rw := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/app", nil)
|
|
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
|
r.RemoteAddr = auditableIP
|
|
|
|
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
|
|
Logger: api.Logger,
|
|
SignedTokenProvider: api.WorkspaceAppsProvider,
|
|
DashboardURL: api.AccessURL,
|
|
PathAppBaseURL: api.AccessURL,
|
|
AppHostname: api.AppHostname,
|
|
AppRequest: req,
|
|
})
|
|
w := rw.Result()
|
|
if !assert.Equal(t, c.ok, ok) {
|
|
dump, err := httputil.DumpResponse(w, true)
|
|
require.NoError(t, err, "error dumping failed response")
|
|
t.Log(string(dump))
|
|
return
|
|
}
|
|
if c.ok {
|
|
require.NotNil(t, token)
|
|
require.Equal(t, token.WorkspaceNameOrID, c.workspace)
|
|
require.Equal(t, token.AgentNameOrID, c.agent)
|
|
require.Equal(t, token.WorkspaceID, workspace.ID)
|
|
require.Equal(t, token.AgentID, agentID)
|
|
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, token.AppSlugOrPort, database.ConnectionTypeWorkspaceApp, me.ID)
|
|
require.Len(t, connLogger.ConnectionLogs(), 1)
|
|
} else {
|
|
require.Nil(t, token)
|
|
require.Len(t, connLogger.ConnectionLogs(), 0)
|
|
}
|
|
_ = w.Body.Close()
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("TokenDoesNotMatchRequest", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
badToken := workspaceapps.SignedToken{
|
|
Request: (workspaceapps.Request{
|
|
AccessMethod: workspaceapps.AccessMethodPath,
|
|
BasePath: "/app",
|
|
UsernameOrID: me.Username,
|
|
WorkspaceNameOrID: workspace.Name,
|
|
AgentNameOrID: agentName,
|
|
// App name differs
|
|
AppSlugOrPort: appNamePublic,
|
|
}).Normalize(),
|
|
RegisteredClaims: jwtutils.RegisteredClaims{
|
|
Expiry: jwt.NewNumericDate(time.Now().Add(time.Minute)),
|
|
},
|
|
UserID: me.ID,
|
|
WorkspaceID: workspace.ID,
|
|
AgentID: agentID,
|
|
AppURL: appURL,
|
|
}
|
|
|
|
badTokenStr, err := jwtutils.Sign(ctx, api.AppSigningKeyCache, badToken)
|
|
require.NoError(t, err)
|
|
|
|
req := (workspaceapps.Request{
|
|
AccessMethod: workspaceapps.AccessMethodPath,
|
|
BasePath: "/app",
|
|
UsernameOrID: me.Username,
|
|
WorkspaceNameOrID: workspace.Name,
|
|
AgentNameOrID: agentName,
|
|
// App name differs
|
|
AppSlugOrPort: appNameOwner,
|
|
}).Normalize()
|
|
|
|
connLogger := connectionlog.NewFake()
|
|
auditableIP := testutil.RandomIPv6(t)
|
|
|
|
rw := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/app", nil)
|
|
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
|
r.AddCookie(&http.Cookie{
|
|
Name: codersdk.SignedAppTokenCookie,
|
|
Value: badTokenStr,
|
|
})
|
|
r.RemoteAddr = auditableIP
|
|
|
|
// Even though the token is invalid, we should still perform request
|
|
// resolution without failure since we'll just ignore the bad token.
|
|
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
|
|
Logger: api.Logger,
|
|
SignedTokenProvider: api.WorkspaceAppsProvider,
|
|
DashboardURL: api.AccessURL,
|
|
PathAppBaseURL: api.AccessURL,
|
|
AppHostname: api.AppHostname,
|
|
AppRequest: req,
|
|
})
|
|
require.True(t, ok)
|
|
require.NotNil(t, token)
|
|
require.Equal(t, appNameOwner, token.AppSlugOrPort)
|
|
|
|
// Cookie should be set in response, and it should be a different
|
|
// token.
|
|
w := rw.Result()
|
|
_ = w.Body.Close()
|
|
cookies := w.Cookies()
|
|
require.Len(t, cookies, 1)
|
|
require.Equal(t, cookies[0].Name, codersdk.SignedAppTokenCookie)
|
|
require.NotEqual(t, cookies[0].Value, badTokenStr)
|
|
var parsedToken workspaceapps.SignedToken
|
|
err = jwtutils.Verify(ctx, api.AppSigningKeyCache, cookies[0].Value, &parsedToken)
|
|
require.NoError(t, err)
|
|
require.Equal(t, appNameOwner, parsedToken.AppSlugOrPort)
|
|
|
|
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, appNameOwner, database.ConnectionTypeWorkspaceApp, me.ID)
|
|
require.Len(t, connLogger.ConnectionLogs(), 1)
|
|
})
|
|
|
|
t.Run("PortPathBlocked", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
req := (workspaceapps.Request{
|
|
AccessMethod: workspaceapps.AccessMethodPath,
|
|
BasePath: "/app",
|
|
UsernameOrID: me.Username,
|
|
WorkspaceNameOrID: workspace.Name,
|
|
AgentNameOrID: agentName,
|
|
AppSlugOrPort: "8080",
|
|
}).Normalize()
|
|
|
|
connLogger := connectionlog.NewFake()
|
|
auditableIP := testutil.RandomIPv6(t)
|
|
|
|
rw := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/app", nil)
|
|
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
|
r.RemoteAddr = auditableIP
|
|
|
|
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
|
|
Logger: api.Logger,
|
|
SignedTokenProvider: api.WorkspaceAppsProvider,
|
|
DashboardURL: api.AccessURL,
|
|
PathAppBaseURL: api.AccessURL,
|
|
AppHostname: api.AppHostname,
|
|
AppRequest: req,
|
|
})
|
|
require.False(t, ok)
|
|
require.Nil(t, token)
|
|
|
|
w := rw.Result()
|
|
_ = w.Body.Close()
|
|
// TODO(mafredri): Verify this is the correct status code.
|
|
require.Equal(t, http.StatusInternalServerError, w.StatusCode)
|
|
require.Len(t, connLogger.ConnectionLogs(), 0, "no connection logs for port path blocked requests")
|
|
})
|
|
|
|
t.Run("PortSubdomain", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
req := (workspaceapps.Request{
|
|
AccessMethod: workspaceapps.AccessMethodSubdomain,
|
|
BasePath: "/",
|
|
UsernameOrID: me.Username,
|
|
WorkspaceNameOrID: workspace.Name,
|
|
AgentNameOrID: agentName,
|
|
AppSlugOrPort: "9090",
|
|
}).Normalize()
|
|
|
|
connLogger := connectionlog.NewFake()
|
|
auditableIP := testutil.RandomIPv6(t)
|
|
|
|
rw := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
|
r.RemoteAddr = auditableIP
|
|
|
|
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
|
|
Logger: api.Logger,
|
|
SignedTokenProvider: api.WorkspaceAppsProvider,
|
|
DashboardURL: api.AccessURL,
|
|
PathAppBaseURL: api.AccessURL,
|
|
AppHostname: api.AppHostname,
|
|
AppRequest: req,
|
|
})
|
|
require.True(t, ok)
|
|
require.Equal(t, req.AppSlugOrPort, token.AppSlugOrPort)
|
|
require.Equal(t, "http://127.0.0.1:9090", token.AppURL)
|
|
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, "9090", database.ConnectionTypePortForwarding, me.ID)
|
|
require.Len(t, connLogger.ConnectionLogs(), 1)
|
|
})
|
|
|
|
t.Run("PortSubdomainHTTPSS", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
req := (workspaceapps.Request{
|
|
AccessMethod: workspaceapps.AccessMethodSubdomain,
|
|
BasePath: "/",
|
|
UsernameOrID: me.Username,
|
|
WorkspaceNameOrID: workspace.Name,
|
|
AgentNameOrID: agentName,
|
|
AppSlugOrPort: "9090ss",
|
|
}).Normalize()
|
|
|
|
connLogger := connectionlog.NewFake()
|
|
auditableIP := testutil.RandomIPv6(t)
|
|
|
|
rw := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
|
r.RemoteAddr = auditableIP
|
|
|
|
_, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
|
|
Logger: api.Logger,
|
|
SignedTokenProvider: api.WorkspaceAppsProvider,
|
|
DashboardURL: api.AccessURL,
|
|
PathAppBaseURL: api.AccessURL,
|
|
AppHostname: api.AppHostname,
|
|
AppRequest: req,
|
|
})
|
|
// should parse as app and fail to find app "9090ss"
|
|
require.False(t, ok)
|
|
w := rw.Result()
|
|
_ = w.Body.Close()
|
|
b, err := io.ReadAll(w.Body)
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(b), "404 - Application Not Found")
|
|
require.Equal(t, http.StatusNotFound, w.StatusCode)
|
|
require.Len(t, connLogger.ConnectionLogs(), 0)
|
|
})
|
|
|
|
t.Run("SubdomainEndsInS", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
req := (workspaceapps.Request{
|
|
AccessMethod: workspaceapps.AccessMethodSubdomain,
|
|
BasePath: "/",
|
|
UsernameOrID: me.Username,
|
|
WorkspaceNameOrID: workspace.Name,
|
|
AgentNameOrID: agentName,
|
|
AppSlugOrPort: appNameEndsInS,
|
|
}).Normalize()
|
|
|
|
connLogger := connectionlog.NewFake()
|
|
auditableIP := testutil.RandomIPv6(t)
|
|
|
|
rw := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/", nil)
|
|
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
|
r.RemoteAddr = auditableIP
|
|
|
|
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
|
|
Logger: api.Logger,
|
|
SignedTokenProvider: api.WorkspaceAppsProvider,
|
|
DashboardURL: api.AccessURL,
|
|
PathAppBaseURL: api.AccessURL,
|
|
AppHostname: api.AppHostname,
|
|
AppRequest: req,
|
|
})
|
|
require.True(t, ok)
|
|
require.Equal(t, req.AppSlugOrPort, token.AppSlugOrPort)
|
|
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, appNameEndsInS, database.ConnectionTypeWorkspaceApp, me.ID)
|
|
require.Len(t, connLogger.ConnectionLogs(), 1)
|
|
})
|
|
|
|
t.Run("Terminal", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
req := (workspaceapps.Request{
|
|
AccessMethod: workspaceapps.AccessMethodTerminal,
|
|
BasePath: "/app",
|
|
AgentNameOrID: agentID.String(),
|
|
}).Normalize()
|
|
|
|
connLogger := connectionlog.NewFake()
|
|
auditableIP := testutil.RandomIPv6(t)
|
|
|
|
rw := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/app", nil)
|
|
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
|
r.RemoteAddr = auditableIP
|
|
|
|
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
|
|
Logger: api.Logger,
|
|
SignedTokenProvider: api.WorkspaceAppsProvider,
|
|
DashboardURL: api.AccessURL,
|
|
PathAppBaseURL: api.AccessURL,
|
|
AppHostname: api.AppHostname,
|
|
AppRequest: req,
|
|
})
|
|
require.True(t, ok)
|
|
require.Equal(t, req.AccessMethod, token.AccessMethod)
|
|
require.Equal(t, req.BasePath, token.BasePath)
|
|
require.Empty(t, token.UsernameOrID)
|
|
require.Empty(t, token.WorkspaceNameOrID)
|
|
require.Equal(t, req.AgentNameOrID, token.Request.AgentNameOrID)
|
|
require.Empty(t, token.AppSlugOrPort)
|
|
require.Empty(t, token.AppURL)
|
|
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, "terminal", database.ConnectionTypeWorkspaceApp, me.ID)
|
|
require.Len(t, connLogger.ConnectionLogs(), 1)
|
|
})
|
|
|
|
t.Run("InsufficientPermissions", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
req := (workspaceapps.Request{
|
|
AccessMethod: workspaceapps.AccessMethodPath,
|
|
BasePath: "/app",
|
|
UsernameOrID: me.Username,
|
|
WorkspaceNameOrID: workspace.Name,
|
|
AgentNameOrID: agentName,
|
|
AppSlugOrPort: appNameOwner,
|
|
}).Normalize()
|
|
|
|
connLogger := connectionlog.NewFake()
|
|
auditableIP := testutil.RandomIPv6(t)
|
|
|
|
rw := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/app", nil)
|
|
r.Header.Set(codersdk.SessionTokenHeader, secondUserClient.SessionToken())
|
|
r.RemoteAddr = auditableIP
|
|
|
|
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
|
|
Logger: api.Logger,
|
|
SignedTokenProvider: api.WorkspaceAppsProvider,
|
|
DashboardURL: api.AccessURL,
|
|
PathAppBaseURL: api.AccessURL,
|
|
AppHostname: api.AppHostname,
|
|
AppRequest: req,
|
|
})
|
|
require.False(t, ok)
|
|
require.Nil(t, token)
|
|
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, appNameOwner, database.ConnectionTypeWorkspaceApp, secondUser.ID)
|
|
require.Len(t, connLogger.ConnectionLogs(), 1)
|
|
})
|
|
|
|
t.Run("UserNotFound", func(t *testing.T) {
|
|
t.Parallel()
|
|
req := (workspaceapps.Request{
|
|
AccessMethod: workspaceapps.AccessMethodPath,
|
|
BasePath: "/app",
|
|
UsernameOrID: "thisuserdoesnotexist",
|
|
WorkspaceNameOrID: workspace.Name,
|
|
AgentNameOrID: agentName,
|
|
AppSlugOrPort: appNameOwner,
|
|
}).Normalize()
|
|
|
|
connLogger := connectionlog.NewFake()
|
|
auditableIP := testutil.RandomIPv6(t)
|
|
|
|
rw := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/app", nil)
|
|
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
|
r.RemoteAddr = auditableIP
|
|
|
|
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
|
|
Logger: api.Logger,
|
|
SignedTokenProvider: api.WorkspaceAppsProvider,
|
|
DashboardURL: api.AccessURL,
|
|
PathAppBaseURL: api.AccessURL,
|
|
AppHostname: api.AppHostname,
|
|
AppRequest: req,
|
|
})
|
|
require.False(t, ok)
|
|
require.Nil(t, token)
|
|
require.Len(t, connLogger.ConnectionLogs(), 0)
|
|
})
|
|
|
|
t.Run("RedirectSubdomainAuth", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
req := (workspaceapps.Request{
|
|
AccessMethod: workspaceapps.AccessMethodSubdomain,
|
|
BasePath: "/",
|
|
UsernameOrID: me.Username,
|
|
WorkspaceNameOrID: workspace.Name,
|
|
AgentNameOrID: agentName,
|
|
AppSlugOrPort: appNameOwner,
|
|
}).Normalize()
|
|
|
|
connLogger := connectionlog.NewFake()
|
|
auditableIP := testutil.RandomIPv6(t)
|
|
|
|
rw := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/some-path", nil)
|
|
// Should not be used as the hostname in the redirect URI.
|
|
r.Host = "app.com"
|
|
r.RemoteAddr = auditableIP
|
|
|
|
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
|
|
Logger: api.Logger,
|
|
SignedTokenProvider: api.WorkspaceAppsProvider,
|
|
DashboardURL: api.AccessURL,
|
|
PathAppBaseURL: api.AccessURL,
|
|
AppHostname: api.AppHostname,
|
|
AppRequest: req,
|
|
AppPath: "/some-path",
|
|
})
|
|
require.False(t, ok)
|
|
require.Nil(t, token)
|
|
|
|
w := rw.Result()
|
|
defer w.Body.Close()
|
|
require.Equal(t, http.StatusSeeOther, w.StatusCode)
|
|
// Note that we don't capture the owner UUID here because the apiKey
|
|
// check/authorization exits early.
|
|
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, appNameOwner, database.ConnectionTypeWorkspaceApp, uuid.Nil)
|
|
require.Len(t, connLogger.ConnectionLogs(), 1)
|
|
|
|
loc, err := w.Location()
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, api.AccessURL.Scheme, loc.Scheme)
|
|
require.Equal(t, api.AccessURL.Host, loc.Host)
|
|
require.Equal(t, "/api/v2/applications/auth-redirect", loc.Path)
|
|
|
|
redirectURIStr := loc.Query().Get(workspaceapps.RedirectURIQueryParam)
|
|
redirectURI, err := url.Parse(redirectURIStr)
|
|
require.NoError(t, err)
|
|
|
|
appHost := appurl.ApplicationURL{
|
|
Prefix: "",
|
|
AppSlugOrPort: req.AppSlugOrPort,
|
|
AgentName: req.AgentNameOrID,
|
|
WorkspaceName: req.WorkspaceNameOrID,
|
|
Username: req.UsernameOrID,
|
|
}
|
|
host := strings.Replace(api.AppHostname, "*", appHost.String(), 1)
|
|
|
|
require.Equal(t, "http", redirectURI.Scheme)
|
|
require.Equal(t, host, redirectURI.Host)
|
|
require.Equal(t, "/some-path", redirectURI.Path)
|
|
})
|
|
|
|
t.Run("UnhealthyAgent", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
req := (workspaceapps.Request{
|
|
AccessMethod: workspaceapps.AccessMethodPath,
|
|
BasePath: "/app",
|
|
UsernameOrID: me.Username,
|
|
WorkspaceNameOrID: workspace.Name,
|
|
AgentNameOrID: agentNameUnhealthy,
|
|
AppSlugOrPort: appNameAgentUnhealthy,
|
|
}).Normalize()
|
|
|
|
connLogger := connectionlog.NewFake()
|
|
auditableIP := testutil.RandomIPv6(t)
|
|
|
|
rw := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/app", nil)
|
|
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
|
r.RemoteAddr = auditableIP
|
|
|
|
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
|
|
Logger: api.Logger,
|
|
SignedTokenProvider: api.WorkspaceAppsProvider,
|
|
DashboardURL: api.AccessURL,
|
|
PathAppBaseURL: api.AccessURL,
|
|
AppHostname: api.AppHostname,
|
|
AppRequest: req,
|
|
})
|
|
require.False(t, ok, "request succeeded even though agent is not connected")
|
|
require.Nil(t, token)
|
|
|
|
w := rw.Result()
|
|
defer w.Body.Close()
|
|
require.Equal(t, http.StatusBadGateway, w.StatusCode)
|
|
assertConnLogContains(t, rw, r, connLogger, workspace, agentNameUnhealthy, appNameAgentUnhealthy, database.ConnectionTypeWorkspaceApp, me.ID)
|
|
require.Len(t, connLogger.ConnectionLogs(), 1)
|
|
|
|
body, err := io.ReadAll(w.Body)
|
|
require.NoError(t, err)
|
|
bodyStr := string(body)
|
|
bodyStr = strings.ReplaceAll(bodyStr, """, `"`)
|
|
// It'll either be "connecting" or "disconnected". Both are OK for this
|
|
// test.
|
|
require.Contains(t, bodyStr, `Agent state is "`)
|
|
})
|
|
|
|
// Initializing apps are now permitted to connect anyways. This wasn't
|
|
// always the case, but we're testing the behavior to ensure it doesn't
|
|
// change back accidentally.
|
|
t.Run("InitializingAppPermitted", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
|
defer cancel()
|
|
|
|
agent, err := client.WorkspaceAgent(ctx, agentID)
|
|
require.NoError(t, err)
|
|
|
|
for _, app := range agent.Apps {
|
|
if app.Slug == appNameInitializing {
|
|
t.Log("app is", app.Health)
|
|
require.Equal(t, codersdk.WorkspaceAppHealthInitializing, app.Health)
|
|
break
|
|
}
|
|
}
|
|
|
|
req := (workspaceapps.Request{
|
|
AccessMethod: workspaceapps.AccessMethodPath,
|
|
BasePath: "/app",
|
|
UsernameOrID: me.Username,
|
|
WorkspaceNameOrID: workspace.Name,
|
|
AgentNameOrID: agentName,
|
|
AppSlugOrPort: appNameInitializing,
|
|
}).Normalize()
|
|
|
|
connLogger := connectionlog.NewFake()
|
|
auditableIP := testutil.RandomIPv6(t)
|
|
|
|
rw := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/app", nil)
|
|
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
|
r.RemoteAddr = auditableIP
|
|
|
|
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
|
|
Logger: api.Logger,
|
|
SignedTokenProvider: api.WorkspaceAppsProvider,
|
|
DashboardURL: api.AccessURL,
|
|
PathAppBaseURL: api.AccessURL,
|
|
AppHostname: api.AppHostname,
|
|
AppRequest: req,
|
|
})
|
|
require.True(t, ok, "ResolveRequest failed, should pass even though app is initializing")
|
|
require.NotNil(t, token)
|
|
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, token.AppSlugOrPort, database.ConnectionTypeWorkspaceApp, me.ID)
|
|
require.Len(t, connLogger.ConnectionLogs(), 1)
|
|
})
|
|
|
|
// Unhealthy apps are now permitted to connect anyways. This wasn't always
|
|
// the case, but we're testing the behavior to ensure it doesn't change back
|
|
// accidentally.
|
|
t.Run("UnhealthyAppPermitted", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
require.Eventually(t, func() bool {
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
|
defer cancel()
|
|
|
|
agent, err := client.WorkspaceAgent(ctx, agentID)
|
|
if err != nil {
|
|
t.Log("could not get agent", err)
|
|
return false
|
|
}
|
|
|
|
for _, app := range agent.Apps {
|
|
if app.Slug == appNameUnhealthy {
|
|
t.Log("app is", app.Health)
|
|
return app.Health == codersdk.WorkspaceAppHealthUnhealthy
|
|
}
|
|
}
|
|
|
|
t.Log("could not find app")
|
|
return false
|
|
}, testutil.WaitLong, testutil.IntervalFast, "wait for app to become unhealthy")
|
|
|
|
req := (workspaceapps.Request{
|
|
AccessMethod: workspaceapps.AccessMethodPath,
|
|
BasePath: "/app",
|
|
UsernameOrID: me.Username,
|
|
WorkspaceNameOrID: workspace.Name,
|
|
AgentNameOrID: agentName,
|
|
AppSlugOrPort: appNameUnhealthy,
|
|
}).Normalize()
|
|
|
|
connLogger := connectionlog.NewFake()
|
|
auditableIP := testutil.RandomIPv6(t)
|
|
|
|
rw := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/app", nil)
|
|
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
|
r.RemoteAddr = auditableIP
|
|
|
|
token, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
|
|
Logger: api.Logger,
|
|
SignedTokenProvider: api.WorkspaceAppsProvider,
|
|
DashboardURL: api.AccessURL,
|
|
PathAppBaseURL: api.AccessURL,
|
|
AppHostname: api.AppHostname,
|
|
AppRequest: req,
|
|
})
|
|
require.True(t, ok, "ResolveRequest failed, should pass even though app is unhealthy")
|
|
require.NotNil(t, token)
|
|
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, token.AppSlugOrPort, database.ConnectionTypeWorkspaceApp, me.ID)
|
|
require.Len(t, connLogger.ConnectionLogs(), 1)
|
|
})
|
|
|
|
t.Run("ConnectionLogging", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, app := range allApps {
|
|
req := (workspaceapps.Request{
|
|
AccessMethod: workspaceapps.AccessMethodPath,
|
|
BasePath: "/app",
|
|
UsernameOrID: me.Username,
|
|
WorkspaceNameOrID: workspace.Name,
|
|
AgentNameOrID: agentName,
|
|
AppSlugOrPort: app,
|
|
}).Normalize()
|
|
|
|
connLogger := connectionlog.NewFake()
|
|
auditableIP := testutil.RandomIPv6(t)
|
|
|
|
t.Log("app", app)
|
|
|
|
// First request, new connection log.
|
|
rw := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/app", nil)
|
|
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
|
r.RemoteAddr = auditableIP
|
|
|
|
_, ok := workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
|
|
Logger: api.Logger,
|
|
SignedTokenProvider: api.WorkspaceAppsProvider,
|
|
DashboardURL: api.AccessURL,
|
|
PathAppBaseURL: api.AccessURL,
|
|
AppHostname: api.AppHostname,
|
|
AppRequest: req,
|
|
})
|
|
require.True(t, ok)
|
|
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, database.ConnectionTypeWorkspaceApp, me.ID)
|
|
require.Len(t, connLogger.ConnectionLogs(), 1)
|
|
|
|
// Second request, no audit log because the session is active.
|
|
rw = httptest.NewRecorder()
|
|
r = httptest.NewRequest("GET", "/app", nil)
|
|
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
|
r.RemoteAddr = auditableIP
|
|
|
|
_, ok = workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
|
|
Logger: api.Logger,
|
|
SignedTokenProvider: api.WorkspaceAppsProvider,
|
|
DashboardURL: api.AccessURL,
|
|
PathAppBaseURL: api.AccessURL,
|
|
AppHostname: api.AppHostname,
|
|
AppRequest: req,
|
|
})
|
|
require.True(t, ok)
|
|
require.Len(t, connLogger.ConnectionLogs(), 1, "single connection log, previous session active")
|
|
|
|
// Third request, session timed out, new audit log.
|
|
rw = httptest.NewRecorder()
|
|
r = httptest.NewRequest("GET", "/app", nil)
|
|
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
|
r.RemoteAddr = auditableIP
|
|
|
|
sessionTimeoutTokenProvider := signedTokenProviderWithConnLogger(t, api.WorkspaceAppsProvider, connLogger, 0)
|
|
_, ok = workspaceappsResolveRequest(t, nil, rw, r, workspaceapps.ResolveRequestOptions{
|
|
Logger: api.Logger,
|
|
SignedTokenProvider: sessionTimeoutTokenProvider,
|
|
DashboardURL: api.AccessURL,
|
|
PathAppBaseURL: api.AccessURL,
|
|
AppHostname: api.AppHostname,
|
|
AppRequest: req,
|
|
})
|
|
require.True(t, ok)
|
|
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, database.ConnectionTypeWorkspaceApp, me.ID)
|
|
require.Len(t, connLogger.ConnectionLogs(), 2, "two connection logs, session timed out")
|
|
|
|
// Fourth request, new IP produces new audit log.
|
|
auditableIP = testutil.RandomIPv6(t)
|
|
rw = httptest.NewRecorder()
|
|
r = httptest.NewRequest("GET", "/app", nil)
|
|
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
|
r.RemoteAddr = auditableIP
|
|
|
|
_, ok = workspaceappsResolveRequest(t, connLogger, rw, r, workspaceapps.ResolveRequestOptions{
|
|
Logger: api.Logger,
|
|
SignedTokenProvider: api.WorkspaceAppsProvider,
|
|
DashboardURL: api.AccessURL,
|
|
PathAppBaseURL: api.AccessURL,
|
|
AppHostname: api.AppHostname,
|
|
AppRequest: req,
|
|
})
|
|
require.True(t, ok)
|
|
assertConnLogContains(t, rw, r, connLogger, workspace, agentName, app, database.ConnectionTypeWorkspaceApp, me.ID)
|
|
require.Len(t, connLogger.ConnectionLogs(), 3, "three connection logs, new IP")
|
|
}
|
|
})
|
|
}
|
|
|
|
func workspaceappsResolveRequest(t testing.TB, connLogger connectionlog.ConnectionLogger, w http.ResponseWriter, r *http.Request, opts workspaceapps.ResolveRequestOptions) (token *workspaceapps.SignedToken, ok bool) {
|
|
t.Helper()
|
|
if opts.SignedTokenProvider != nil && connLogger != nil {
|
|
opts.SignedTokenProvider = signedTokenProviderWithConnLogger(t, opts.SignedTokenProvider, connLogger, time.Hour)
|
|
}
|
|
if opts.Cookies.PathAppSessionToken == "" {
|
|
opts.Cookies.PathAppSessionToken = codersdk.PathAppSessionTokenCookie
|
|
}
|
|
if opts.Cookies.SubdomainAppSessionToken == "" {
|
|
opts.Cookies.SubdomainAppSessionToken = codersdk.SubdomainAppSessionTokenCookie + "_test"
|
|
}
|
|
if opts.Cookies.SignedAppToken == "" {
|
|
opts.Cookies.SignedAppToken = codersdk.SignedAppTokenCookie
|
|
}
|
|
|
|
tracing.StatusWriterMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
httpmw.AttachRequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
token, ok = workspaceapps.ResolveRequest(w, r, opts)
|
|
})).ServeHTTP(w, r)
|
|
})).ServeHTTP(w, r)
|
|
|
|
return token, ok
|
|
}
|
|
|
|
func signedTokenProviderWithConnLogger(t testing.TB, provider workspaceapps.SignedTokenProvider, connLogger connectionlog.ConnectionLogger, sessionTimeout time.Duration) workspaceapps.SignedTokenProvider {
|
|
t.Helper()
|
|
p, ok := provider.(*workspaceapps.DBTokenProvider)
|
|
require.True(t, ok, "provider is not a DBTokenProvider")
|
|
|
|
shallowCopy := *p
|
|
shallowCopy.ConnectionLogger = &atomic.Pointer[connectionlog.ConnectionLogger]{}
|
|
shallowCopy.ConnectionLogger.Store(&connLogger)
|
|
shallowCopy.WorkspaceAppAuditSessionTimeout = sessionTimeout
|
|
return &shallowCopy
|
|
}
|
|
|
|
func assertConnLogContains(t *testing.T, rr *httptest.ResponseRecorder, r *http.Request, connLogger *connectionlog.FakeConnectionLogger, workspace codersdk.Workspace, agentName string, slugOrPort string, typ database.ConnectionType, userID uuid.UUID) {
|
|
t.Helper()
|
|
|
|
resp := rr.Result()
|
|
defer resp.Body.Close()
|
|
|
|
require.True(t, connLogger.Contains(t, database.UpsertConnectionLogParams{
|
|
OrganizationID: workspace.OrganizationID,
|
|
WorkspaceOwnerID: workspace.OwnerID,
|
|
WorkspaceID: workspace.ID,
|
|
WorkspaceName: workspace.Name,
|
|
AgentName: agentName,
|
|
Type: typ,
|
|
Ip: database.ParseIP(r.RemoteAddr),
|
|
UserAgent: sql.NullString{Valid: r.UserAgent() != "", String: r.UserAgent()},
|
|
Code: sql.NullInt32{
|
|
Int32: int32(resp.StatusCode), // nolint:gosec
|
|
Valid: true,
|
|
},
|
|
UserID: uuid.NullUUID{
|
|
UUID: userID,
|
|
Valid: true,
|
|
},
|
|
SlugOrPort: sql.NullString{Valid: slugOrPort != "", String: slugOrPort},
|
|
}))
|
|
}
|