Files
coder/coderd/insights_test.go
Mathias Fredriksson b79167293c chore(Makefile): update golden files as part of make gen (#17039)
Updating golden files is an unnecessary extra step in addition to gen
that is easily overlooked, leading to the developer noticing the issue
in CI leading to lost developer time waiting for tests to complete.
2025-03-21 13:04:30 +00:00

2373 lines
77 KiB
Go

package coderd_test
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/agent/agenttest"
agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbrollup"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/workspaceapps"
"github.com/coder/coder/v2/coderd/workspacestats"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/coder/v2/testutil"
)
func TestDeploymentInsights(t *testing.T) {
t.Parallel()
clientTz, err := time.LoadLocation("America/Chicago")
require.NoError(t, err)
db, ps := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure())
logger := testutil.Logger(t)
rollupEvents := make(chan dbrollup.Event)
statsInterval := 500 * time.Millisecond
// Speed up the test by controlling batch size and interval.
batcher, closeBatcher, err := workspacestats.NewBatcher(context.Background(),
workspacestats.BatcherWithLogger(logger.Named("batcher").Leveled(slog.LevelDebug)),
workspacestats.BatcherWithStore(db),
workspacestats.BatcherWithBatchSize(1),
workspacestats.BatcherWithInterval(statsInterval),
)
require.NoError(t, err)
defer closeBatcher()
client := coderdtest.New(t, &coderdtest.Options{
Database: db,
Pubsub: ps,
Logger: &logger,
IncludeProvisionerDaemon: true,
AgentStatsRefreshInterval: statsInterval,
StatsBatcher: batcher,
DatabaseRolluper: dbrollup.New(
logger.Named("dbrollup").Leveled(slog.LevelDebug),
db,
dbrollup.WithInterval(statsInterval/2),
dbrollup.WithEventChannel(rollupEvents),
),
})
user := coderdtest.CreateFirstUser(t, client)
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.PlanComplete,
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
require.Empty(t, template.BuildTimeStats[codersdk.WorkspaceTransitionStart])
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
ctx := testutil.Context(t, testutil.WaitSuperLong)
// Pre-check, no permission issues.
daus, err := client.DeploymentDAUs(ctx, codersdk.TimezoneOffsetHour(clientTz))
require.NoError(t, err)
_ = agenttest.New(t, client.URL, authToken)
resources := coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
conn, err := workspacesdk.New(client).
DialAgent(ctx, resources[0].Agents[0].ID, &workspacesdk.DialAgentOptions{
Logger: testutil.Logger(t).Named("dialagent"),
})
require.NoError(t, err)
defer conn.Close()
sshConn, err := conn.SSHClient(ctx)
require.NoError(t, err)
defer sshConn.Close()
sess, err := sshConn.NewSession()
require.NoError(t, err)
defer sess.Close()
r, w := io.Pipe()
defer r.Close()
defer w.Close()
sess.Stdin = r
sess.Stdout = io.Discard
err = sess.Start("cat")
require.NoError(t, err)
select {
case <-ctx.Done():
require.Fail(t, "timed out waiting for initial rollup event", ctx.Err())
case ev := <-rollupEvents:
require.True(t, ev.Init, "want init event")
}
for {
select {
case <-ctx.Done():
require.Fail(t, "timed out waiting for deployment daus to update", daus)
case <-rollupEvents:
}
daus, err = client.DeploymentDAUs(ctx, codersdk.TimezoneOffsetHour(clientTz))
require.NoError(t, err)
if len(daus.Entries) > 0 && daus.Entries[len(daus.Entries)-1].Amount > 0 {
break
}
t.Logf("waiting for deployment daus to update: %+v", daus)
}
}
func TestUserActivityInsights_SanityCheck(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
logger := testutil.Logger(t)
client := coderdtest.New(t, &coderdtest.Options{
Database: db,
Pubsub: ps,
Logger: &logger,
IncludeProvisionerDaemon: true,
AgentStatsRefreshInterval: time.Millisecond * 100,
DatabaseRolluper: dbrollup.New(
logger.Named("dbrollup"),
db,
dbrollup.WithInterval(time.Millisecond*100),
),
})
// Create two users, one that will appear in the report and another that
// won't (due to not having/using a workspace).
user := coderdtest.CreateFirstUser(t, client)
_, _ = coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.PlanComplete,
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
require.Empty(t, template.BuildTimeStats[codersdk.WorkspaceTransitionStart])
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
// Start an agent so that we can generate stats.
_ = agenttest.New(t, client.URL, authToken)
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
// Start must be at the beginning of the day, initialize it early in case
// the day changes so that we get the relevant stats faster.
y, m, d := time.Now().UTC().Date()
today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong)
defer cancel()
// Connect to the agent to generate usage/latency stats.
conn, err := workspacesdk.New(client).
DialAgent(ctx, resources[0].Agents[0].ID, &workspacesdk.DialAgentOptions{
Logger: logger.Named("client"),
})
require.NoError(t, err)
defer conn.Close()
sshConn, err := conn.SSHClient(ctx)
require.NoError(t, err)
defer sshConn.Close()
sess, err := sshConn.NewSession()
require.NoError(t, err)
defer sess.Close()
r, w := io.Pipe()
defer r.Close()
defer w.Close()
sess.Stdin = r
sess.Stdout = io.Discard
err = sess.Start("cat")
require.NoError(t, err)
var userActivities codersdk.UserActivityInsightsResponse
require.Eventuallyf(t, func() bool {
// Keep connection active.
_, err := w.Write([]byte("hello world\n"))
if !assert.NoError(t, err) {
return false
}
userActivities, err = client.UserActivityInsights(ctx, codersdk.UserActivityInsightsRequest{
StartTime: today,
EndTime: time.Now().UTC().Truncate(time.Hour).Add(time.Hour), // Round up to include the current hour.
TemplateIDs: []uuid.UUID{template.ID},
})
if !assert.NoError(t, err) {
return false
}
return len(userActivities.Report.Users) > 0 && userActivities.Report.Users[0].Seconds > 0
}, testutil.WaitSuperLong, testutil.IntervalMedium, "user activity is missing")
// We got our latency data, close the connection.
_ = sess.Close()
_ = sshConn.Close()
require.Len(t, userActivities.Report.Users, 1, "want only 1 user")
require.Equal(t, userActivities.Report.Users[0].UserID, user.UserID, "want user id to match")
assert.Greater(t, userActivities.Report.Users[0].Seconds, int64(0), "want usage in seconds to be greater than 0")
}
func TestUserLatencyInsights(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
client := coderdtest.New(t, &coderdtest.Options{
Database: db,
Pubsub: ps,
Logger: &logger,
IncludeProvisionerDaemon: true,
AgentStatsRefreshInterval: time.Millisecond * 50,
DatabaseRolluper: dbrollup.New(
logger.Named("dbrollup"),
db,
dbrollup.WithInterval(time.Millisecond*100),
),
})
// Create two users, one that will appear in the report and another that
// won't (due to not having/using a workspace).
user := coderdtest.CreateFirstUser(t, client)
_, _ = coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.PlanComplete,
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
require.Empty(t, template.BuildTimeStats[codersdk.WorkspaceTransitionStart])
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
// Start an agent so that we can generate stats.
_ = agenttest.New(t, client.URL, authToken)
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
// Start must be at the beginning of the day, initialize it early in case
// the day changes so that we get the relevant stats faster.
y, m, d := time.Now().UTC().Date()
today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
// Connect to the agent to generate usage/latency stats.
conn, err := workspacesdk.New(client).
DialAgent(ctx, resources[0].Agents[0].ID, &workspacesdk.DialAgentOptions{
Logger: logger.Named("client"),
})
require.NoError(t, err)
defer conn.Close()
sshConn, err := conn.SSHClient(ctx)
require.NoError(t, err)
defer sshConn.Close()
sess, err := sshConn.NewSession()
require.NoError(t, err)
defer sess.Close()
r, w := io.Pipe()
defer r.Close()
defer w.Close()
sess.Stdin = r
sess.Stdout = io.Discard
err = sess.Start("cat")
require.NoError(t, err)
var userLatencies codersdk.UserLatencyInsightsResponse
require.Eventuallyf(t, func() bool {
// Keep connection active.
_, err := w.Write([]byte("hello world\n"))
if !assert.NoError(t, err) {
return false
}
userLatencies, err = client.UserLatencyInsights(ctx, codersdk.UserLatencyInsightsRequest{
StartTime: today,
EndTime: time.Now().UTC().Truncate(time.Hour).Add(time.Hour), // Round up to include the current hour.
TemplateIDs: []uuid.UUID{template.ID},
})
if !assert.NoError(t, err) {
return false
}
return len(userLatencies.Report.Users) > 0 && userLatencies.Report.Users[0].LatencyMS.P50 > 0
}, testutil.WaitMedium, testutil.IntervalFast, "user latency is missing")
// We got our latency data, close the connection.
_ = sess.Close()
_ = sshConn.Close()
require.Len(t, userLatencies.Report.Users, 1, "want only 1 user")
require.Equal(t, userLatencies.Report.Users[0].UserID, user.UserID, "want user id to match")
assert.Greater(t, userLatencies.Report.Users[0].LatencyMS.P50, float64(0), "want p50 to be greater than 0")
assert.Greater(t, userLatencies.Report.Users[0].LatencyMS.P95, float64(0), "want p95 to be greater than 0")
}
func TestUserLatencyInsights_BadRequest(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{})
_ = coderdtest.CreateFirstUser(t, client)
y, m, d := time.Now().UTC().Date()
today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
_, err := client.UserLatencyInsights(ctx, codersdk.UserLatencyInsightsRequest{
StartTime: today,
EndTime: today.AddDate(0, 0, -1),
})
assert.Error(t, err, "want error for end time before start time")
}
func TestUserActivityInsights_BadRequest(t *testing.T) {
t.Parallel()
saoPaulo, err := time.LoadLocation("America/Sao_Paulo")
require.NoError(t, err)
y, m, d := time.Now().UTC().Date()
today := time.Date(y, m, d, 0, 0, 0, 0, saoPaulo)
// Prepare
client := coderdtest.New(t, &coderdtest.Options{})
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
// Send insights request
_, err = client.UserActivityInsights(ctx, codersdk.UserActivityInsightsRequest{
StartTime: today,
EndTime: today.AddDate(0, 0, -1),
})
assert.Error(t, err, "want error for end time before start time")
}
func TestTemplateInsights_Golden(t *testing.T) {
t.Parallel()
// Prepare test data types.
type templateParameterOption struct {
name string
value string
}
type templateParameter struct {
name string
description string
options []templateParameterOption
}
type templateApp struct {
name string
icon string
}
type testTemplate struct {
name string
parameters []*templateParameter
apps []templateApp
// Filled later.
id uuid.UUID
}
type buildParameter struct {
templateParameter *templateParameter
value string
}
type workspaceApp templateApp
type testWorkspace struct {
name string
template *testTemplate
buildParameters []buildParameter
// Filled later.
id uuid.UUID
user any // *testUser, but it's not available yet, defined below.
agentID uuid.UUID
apps []*workspaceApp
agentClient *agentsdk.Client
}
type testUser struct {
name string
workspaces []*testWorkspace
client *codersdk.Client
sdk codersdk.User
}
// Represent agent stats, to be inserted via stats batcher.
type agentStat struct {
// Set a range via start/end, multiple stats will be generated
// within the range.
startedAt time.Time
endedAt time.Time
sessionCountVSCode int64
sessionCountJetBrains int64
sessionCountReconnectingPTY int64
sessionCountSSH int64
noConnections bool
}
// Represent app usage stats, to be inserted via stats reporter.
type appUsage struct {
app *workspaceApp
startedAt time.Time
endedAt time.Time
requests int
}
// Represent actual data being generated on a per-workspace basis.
type testDataGen struct {
agentStats []agentStat
appUsage []appUsage
}
prepareFixtureAndTestData := func(t *testing.T, makeFixture func() ([]*testTemplate, []*testUser), makeData func([]*testTemplate, []*testUser) map[*testWorkspace]testDataGen) ([]*testTemplate, []*testUser, map[*testWorkspace]testDataGen) {
var stableIDs []uuid.UUID
newStableUUID := func() uuid.UUID {
stableIDs = append(stableIDs, uuid.MustParse(fmt.Sprintf("00000000-0000-0000-0000-%012d", len(stableIDs)+1)))
stableID := stableIDs[len(stableIDs)-1]
return stableID
}
templates, users := makeFixture()
for _, template := range templates {
template.id = newStableUUID()
}
for _, user := range users {
for _, workspace := range user.workspaces {
workspace.user = user
for _, app := range workspace.template.apps {
app := workspaceApp(app)
workspace.apps = append(workspace.apps, &app)
}
for _, bp := range workspace.buildParameters {
foundBuildParam := false
for _, param := range workspace.template.parameters {
if bp.templateParameter == param {
foundBuildParam = true
break
}
}
require.True(t, foundBuildParam, "test bug: parameter not in workspace %s template %q", workspace.name, workspace.template.name)
}
}
}
testData := makeData(templates, users)
// Sanity check.
for ws, data := range testData {
for _, usage := range data.appUsage {
found := false
for _, app := range ws.apps {
if usage.app == app { // Pointer equality
found = true
break
}
}
if !found {
for _, user := range users {
for _, workspace := range user.workspaces {
for _, app := range workspace.apps {
if usage.app == app { // Pointer equality
require.True(t, found, "test bug: app %q not in workspace %q: want user=%s workspace=%s; got user=%s workspace=%s ", usage.app.name, ws.name, ws.user.(*testUser).name, ws.name, user.name, workspace.name)
break
}
}
}
}
require.True(t, found, "test bug: app %q not in workspace %q", usage.app.name, ws.name)
}
}
}
return templates, users, testData
}
prepare := func(t *testing.T, templates []*testTemplate, users []*testUser, testData map[*testWorkspace]testDataGen) (*codersdk.Client, chan dbrollup.Event) {
logger := testutil.Logger(t)
db, ps := dbtestutil.NewDB(t)
events := make(chan dbrollup.Event)
client := coderdtest.New(t, &coderdtest.Options{
Database: db,
Pubsub: ps,
Logger: &logger,
IncludeProvisionerDaemon: true,
AgentStatsRefreshInterval: time.Hour, // Not relevant for this test.
DatabaseRolluper: dbrollup.New(
logger.Named("dbrollup"),
db,
dbrollup.WithInterval(time.Millisecond*50),
dbrollup.WithEventChannel(events),
),
})
firstUser := coderdtest.CreateFirstUser(t, client)
// Prepare all test users.
for _, user := range users {
user.client, user.sdk = coderdtest.CreateAnotherUserMutators(t, client, firstUser.OrganizationID, nil, func(r *codersdk.CreateUserRequestWithOrgs) {
r.Username = user.name
})
user.client.SetLogger(logger.Named("user").With(slog.Field{Name: "name", Value: user.name}))
}
// Prepare all the templates.
for _, template := range templates {
template := template
var parameters []*proto.RichParameter
for _, parameter := range template.parameters {
var options []*proto.RichParameterOption
var defaultValue string
for _, option := range parameter.options {
if defaultValue == "" {
defaultValue = option.value
}
options = append(options, &proto.RichParameterOption{
Name: option.name,
Value: option.value,
})
}
parameters = append(parameters, &proto.RichParameter{
Name: parameter.name,
DisplayName: parameter.name,
Type: "string",
Description: parameter.description,
Options: options,
DefaultValue: defaultValue,
})
}
// Prepare all workspace resources (agents and apps).
var (
createWorkspaces []func(uuid.UUID)
waitWorkspaces []func()
)
var resources []*proto.Resource
for _, user := range users {
user := user
for _, workspace := range user.workspaces {
workspace := workspace
if workspace.template != template {
continue
}
authToken := uuid.New()
agentClient := agentsdk.New(client.URL)
agentClient.SetSessionToken(authToken.String())
workspace.agentClient = agentClient
var apps []*proto.App
for _, app := range workspace.apps {
apps = append(apps, &proto.App{
Slug: app.name,
DisplayName: app.name,
Icon: app.icon,
SharingLevel: proto.AppSharingLevel_OWNER,
Url: "http://",
})
}
resources = append(resources, &proto.Resource{
Name: "example",
Type: "aws_instance",
Agents: []*proto.Agent{{
Id: uuid.NewString(), // Doesn't matter, not used in DB.
Name: "dev",
Auth: &proto.Agent_Token{
Token: authToken.String(),
},
Apps: apps,
}},
})
var buildParameters []codersdk.WorkspaceBuildParameter
for _, buildParameter := range workspace.buildParameters {
buildParameters = append(buildParameters, codersdk.WorkspaceBuildParameter{
Name: buildParameter.templateParameter.name,
Value: buildParameter.value,
})
}
createWorkspaces = append(createWorkspaces, func(templateID uuid.UUID) {
// Create workspace using the users client.
createdWorkspace := coderdtest.CreateWorkspace(t, user.client, templateID, func(cwr *codersdk.CreateWorkspaceRequest) {
cwr.RichParameterValues = buildParameters
})
workspace.id = createdWorkspace.ID
waitWorkspaces = append(waitWorkspaces, func() {
coderdtest.AwaitWorkspaceBuildJobCompleted(t, user.client, createdWorkspace.LatestBuild.ID)
ctx := testutil.Context(t, testutil.WaitShort)
ws, err := user.client.Workspace(ctx, workspace.id)
require.NoError(t, err, "want no error getting workspace")
workspace.agentID = ws.LatestBuild.Resources[0].Agents[0].ID
})
})
}
}
// Create the template version and template.
version := coderdtest.CreateTemplateVersion(t, client, firstUser.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: []*proto.Response{
{
Type: &proto.Response_Plan{
Plan: &proto.PlanComplete{
Parameters: parameters,
},
},
},
},
ProvisionApply: []*proto.Response{{
Type: &proto.Response_Apply{
Apply: &proto.ApplyComplete{
Resources: resources,
},
},
}},
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
// Create template, essentially a modified version of CreateTemplate
// where we can control the template ID.
// createdTemplate := coderdtest.CreateTemplate(t, client, firstUser.OrganizationID, version.ID)
createdTemplate := dbgen.Template(t, db, database.Template{
ID: template.id,
ActiveVersionID: version.ID,
OrganizationID: firstUser.OrganizationID,
CreatedBy: firstUser.UserID,
GroupACL: database.TemplateACL{
firstUser.OrganizationID.String(): db2sdk.TemplateRoleActions(codersdk.TemplateRoleUse),
},
})
err := db.UpdateTemplateVersionByID(context.Background(), database.UpdateTemplateVersionByIDParams{
ID: version.ID,
TemplateID: uuid.NullUUID{
UUID: createdTemplate.ID,
Valid: true,
},
})
require.NoError(t, err, "want no error updating template version")
// Create all workspaces and wait for them.
for _, createWorkspace := range createWorkspaces {
createWorkspace(template.id)
}
for _, waitWorkspace := range waitWorkspaces {
waitWorkspace()
}
}
ctx := testutil.Context(t, testutil.WaitSuperLong)
// Use agent stats batcher to insert agent stats, similar to live system.
// NOTE(mafredri): Ideally we would pass batcher as a coderd option and
// insert using the agentClient, but we have a circular dependency on
// the database.
batcher, batcherCloser, err := workspacestats.NewBatcher(
ctx,
workspacestats.BatcherWithStore(db),
workspacestats.BatcherWithLogger(logger.Named("batchstats")),
workspacestats.BatcherWithInterval(time.Hour),
)
require.NoError(t, err)
defer batcherCloser() // Flushes the stats, this is to ensure they're written.
for workspace, data := range testData {
for _, stat := range data.agentStats {
createdAt := stat.startedAt
connectionCount := int64(1)
if stat.noConnections {
connectionCount = 0
}
for createdAt.Before(stat.endedAt) {
batcher.Add(createdAt, workspace.agentID, workspace.template.id, workspace.user.(*testUser).sdk.ID, workspace.id, &agentproto.Stats{
ConnectionCount: connectionCount,
SessionCountVscode: stat.sessionCountVSCode,
SessionCountJetbrains: stat.sessionCountJetBrains,
SessionCountReconnectingPty: stat.sessionCountReconnectingPTY,
SessionCountSsh: stat.sessionCountSSH,
}, false)
createdAt = createdAt.Add(30 * time.Second)
}
}
}
// Insert app usage.
var stats []workspaceapps.StatsReport
for workspace, data := range testData {
for _, usage := range data.appUsage {
appName := usage.app.name
accessMethod := workspaceapps.AccessMethodPath
if usage.app.name == "terminal" {
appName = ""
accessMethod = workspaceapps.AccessMethodTerminal
}
stats = append(stats, workspaceapps.StatsReport{
UserID: workspace.user.(*testUser).sdk.ID,
WorkspaceID: workspace.id,
AgentID: workspace.agentID,
AccessMethod: accessMethod,
SlugOrPort: appName,
SessionID: uuid.New(),
SessionStartedAt: usage.startedAt,
SessionEndedAt: usage.endedAt,
Requests: usage.requests,
})
}
}
reporter := workspacestats.NewReporter(workspacestats.ReporterOptions{
Database: db,
AppStatBatchSize: workspaceapps.DefaultStatsDBReporterBatchSize,
})
//nolint:gocritic // This is a test.
err = reporter.ReportAppStats(dbauthz.AsSystemRestricted(ctx), stats)
require.NoError(t, err, "want no error inserting app stats")
return client, events
}
baseTemplateAndUserFixture := func() ([]*testTemplate, []*testUser) {
// Test templates and configuration to generate.
templates := []*testTemplate{
// Create two templates with near-identical apps and parameters
// to allow testing for grouping similar data.
{
name: "template1",
parameters: []*templateParameter{
{name: "param1", description: "This is first parameter"},
{name: "param2", description: "This is second parameter"},
{name: "param3", description: "This is third parameter"},
{
name: "param4",
description: "This is fourth parameter",
options: []templateParameterOption{
{name: "option1", value: "option1"},
{name: "option2", value: "option2"},
},
},
},
apps: []templateApp{
{name: "app1", icon: "/icon1.png"},
{name: "app2", icon: "/icon2.png"},
{name: "app3", icon: "/icon2.png"},
},
},
{
name: "template2",
parameters: []*templateParameter{
{name: "param1", description: "This is first parameter"},
{name: "param2", description: "This is second parameter"},
{name: "param3", description: "This is third parameter"},
},
apps: []templateApp{
{name: "app1", icon: "/icon1.png"},
{name: "app2", icon: "/icon2.png"},
{name: "app3", icon: "/icon2.png"},
},
},
// Create another template with different parameters and apps.
{
name: "othertemplate",
parameters: []*templateParameter{
{name: "otherparam1", description: "This is another parameter"},
},
apps: []templateApp{
{name: "otherapp1", icon: "/icon1.png"},
// This "special test app" will be converted into web
// terminal usage, this is not included in stats since we
// currently rely on agent stats for this data.
{name: "terminal", icon: "/terminal.png"},
},
},
}
// Users and workspaces to generate.
users := []*testUser{
{
name: "user1",
workspaces: []*testWorkspace{
{
name: "workspace1",
template: templates[0],
buildParameters: []buildParameter{
{templateParameter: templates[0].parameters[0], value: "abc"},
{templateParameter: templates[0].parameters[1], value: "123"},
{templateParameter: templates[0].parameters[2], value: "bbb"},
{templateParameter: templates[0].parameters[3], value: "option1"},
},
},
{
name: "workspace2",
template: templates[1],
buildParameters: []buildParameter{
{templateParameter: templates[1].parameters[0], value: "ABC"},
{templateParameter: templates[1].parameters[1], value: "123"},
{templateParameter: templates[1].parameters[2], value: "BBB"},
},
},
{
name: "otherworkspace3",
template: templates[2],
},
},
},
{
name: "user2",
workspaces: []*testWorkspace{
{
name: "workspace1",
template: templates[0],
buildParameters: []buildParameter{
{templateParameter: templates[0].parameters[0], value: "abc"},
{templateParameter: templates[0].parameters[1], value: "123"},
{templateParameter: templates[0].parameters[2], value: "BBB"},
{templateParameter: templates[0].parameters[3], value: "option1"},
},
},
},
},
{
name: "user3",
workspaces: []*testWorkspace{
{
name: "otherworkspace1",
template: templates[2],
buildParameters: []buildParameter{
{templateParameter: templates[2].parameters[0], value: "xyz"},
},
},
{
name: "workspace2",
template: templates[0],
buildParameters: []buildParameter{
{templateParameter: templates[0].parameters[3], value: "option2"},
},
},
},
},
}
return templates, users
}
// Time range for report, test data will be generated within and
// outside this range, but only data within the range should be
// included in the report.
frozenLastNight := time.Date(2023, 8, 22, 0, 0, 0, 0, time.UTC)
frozenWeekAgo := frozenLastNight.AddDate(0, 0, -7)
saoPaulo, err := time.LoadLocation("America/Sao_Paulo")
require.NoError(t, err)
frozenWeekAgoSaoPaulo, err := time.ParseInLocation(time.DateTime, frozenWeekAgo.Format(time.DateTime), saoPaulo)
require.NoError(t, err)
//nolint:dupl // For testing purposes
makeBaseTestData := func(templates []*testTemplate, users []*testUser) map[*testWorkspace]testDataGen {
return map[*testWorkspace]testDataGen{
users[0].workspaces[0]: {
agentStats: []agentStat{
{ // One hour of usage.
startedAt: frozenWeekAgo,
endedAt: frozenWeekAgo.Add(time.Hour),
sessionCountVSCode: 1,
sessionCountSSH: 1,
},
{ // 12 minutes of usage.
startedAt: frozenWeekAgo.AddDate(0, 0, 1),
endedAt: frozenWeekAgo.AddDate(0, 0, 1).Add(12 * time.Minute),
sessionCountSSH: 1,
},
{ // 1m30s of usage -> 2m rounded.
startedAt: frozenWeekAgo.AddDate(0, 0, 2).Add(4*time.Minute + 30*time.Second),
endedAt: frozenWeekAgo.AddDate(0, 0, 2).Add(6 * time.Minute),
sessionCountJetBrains: 1,
},
},
appUsage: []appUsage{
{ // One hour of usage.
app: users[0].workspaces[0].apps[0],
startedAt: frozenWeekAgo,
endedAt: frozenWeekAgo.Add(time.Hour),
requests: 1,
},
{ // 30s of app usage -> 1m rounded.
app: users[0].workspaces[0].apps[0],
startedAt: frozenWeekAgo.Add(2*time.Hour + 10*time.Second),
endedAt: frozenWeekAgo.Add(2*time.Hour + 40*time.Second),
requests: 1,
},
{ // 1m30s of app usage -> 2m rounded (included in São Paulo).
app: users[0].workspaces[0].apps[0],
startedAt: frozenWeekAgo.Add(3*time.Hour + 30*time.Second),
endedAt: frozenWeekAgo.Add(3*time.Hour + 90*time.Second),
requests: 1,
},
{ // used an app on the last day, counts as active user, 12m.
app: users[0].workspaces[0].apps[2],
startedAt: frozenWeekAgo.AddDate(0, 0, 6),
endedAt: frozenWeekAgo.AddDate(0, 0, 6).Add(12 * time.Minute),
requests: 1,
},
},
},
users[0].workspaces[1]: {
agentStats: []agentStat{
{
// One hour of usage in second template at the same time
// as in first template. When selecting both templates
// this user and their app usage will only be counted
// once but the template ID will show up in the data.
startedAt: frozenWeekAgo,
endedAt: frozenWeekAgo.Add(time.Hour),
sessionCountVSCode: 1,
sessionCountSSH: 1,
},
{ // One hour of usage.
startedAt: frozenWeekAgo.AddDate(0, 0, -12),
endedAt: frozenWeekAgo.AddDate(0, 0, -12).Add(time.Hour),
sessionCountSSH: 1,
sessionCountReconnectingPTY: 1,
},
{ // Another one hour of usage, but "active users" shouldn't be increased twice.
startedAt: frozenWeekAgo.AddDate(0, 0, -10),
endedAt: frozenWeekAgo.AddDate(0, 0, -10).Add(time.Hour),
sessionCountSSH: 1,
sessionCountReconnectingPTY: 1,
},
},
appUsage: []appUsage{
{ // One hour of usage, but same user and same template app, only count once.
app: users[0].workspaces[1].apps[0],
startedAt: frozenWeekAgo,
endedAt: frozenWeekAgo.Add(time.Hour),
requests: 1,
},
{
// Different templates but identical apps, apps will be
// combined and usage will be summed.
app: users[0].workspaces[1].apps[0],
startedAt: frozenWeekAgo.AddDate(0, 0, 2),
endedAt: frozenWeekAgo.AddDate(0, 0, 2).Add(6 * time.Hour),
requests: 1,
},
},
},
users[0].workspaces[2]: {
agentStats: []agentStat{},
appUsage: []appUsage{},
},
users[1].workspaces[0]: {
agentStats: []agentStat{
{ // One hour of agent usage before timeframe (exclude).
startedAt: frozenWeekAgo.Add(-time.Hour),
endedAt: frozenWeekAgo,
sessionCountVSCode: 1,
sessionCountSSH: 1,
},
{ // One hour of usage.
startedAt: frozenWeekAgo,
endedAt: frozenWeekAgo.Add(time.Hour),
sessionCountSSH: 1,
},
{ // One hour of agent usage after timeframe (exclude in UTC, include in São Paulo).
startedAt: frozenWeekAgo.AddDate(0, 0, 7),
endedAt: frozenWeekAgo.AddDate(0, 0, 7).Add(time.Hour),
sessionCountVSCode: 1,
sessionCountSSH: 1,
},
},
appUsage: []appUsage{
{ // One hour of app usage before timeframe (exclude).
app: users[1].workspaces[0].apps[2],
startedAt: frozenWeekAgo.Add(-time.Hour),
endedAt: frozenWeekAgo,
requests: 1,
},
{ // One hour of app usage after timeframe (exclude in UTC, include in São Paulo).
app: users[1].workspaces[0].apps[2],
startedAt: frozenWeekAgo.AddDate(0, 0, 7),
endedAt: frozenWeekAgo.AddDate(0, 0, 7).Add(time.Hour),
requests: 1,
},
},
},
users[2].workspaces[0]: {
agentStats: []agentStat{
{ // One hour of usage.
startedAt: frozenWeekAgo,
endedAt: frozenWeekAgo.Add(time.Hour),
sessionCountSSH: 1,
sessionCountReconnectingPTY: 1,
},
},
appUsage: []appUsage{
{
app: users[2].workspaces[0].apps[0],
startedAt: frozenWeekAgo.AddDate(0, 0, 2),
endedAt: frozenWeekAgo.AddDate(0, 0, 2).Add(5 * time.Minute),
requests: 1,
},
{ // Special app; excluded from apps, but counted as active during the day.
app: users[2].workspaces[0].apps[1],
startedAt: frozenWeekAgo.AddDate(0, 0, 3),
endedAt: frozenWeekAgo.AddDate(0, 0, 3).Add(5 * time.Minute),
requests: 1,
},
},
},
}
}
type testRequest struct {
name string
makeRequest func([]*testTemplate) codersdk.TemplateInsightsRequest
ignoreTimes bool
}
tests := []struct {
name string
makeFixture func() ([]*testTemplate, []*testUser)
makeTestData func([]*testTemplate, []*testUser) map[*testWorkspace]testDataGen
requests []testRequest
}{
{
name: "multiple users and workspaces",
makeFixture: baseTemplateAndUserFixture,
makeTestData: makeBaseTestData,
requests: []testRequest{
{
name: "week deployment wide",
makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest {
return codersdk.TemplateInsightsRequest{
StartTime: frozenWeekAgo,
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
Interval: codersdk.InsightsReportIntervalDay,
}
},
},
{
name: "weekly aggregated deployment wide",
makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest {
return codersdk.TemplateInsightsRequest{
StartTime: frozenWeekAgo.AddDate(0, 0, -3),
EndTime: frozenWeekAgo.AddDate(0, 0, 4),
Interval: codersdk.InsightsReportIntervalWeek,
}
},
},
{
name: "week all templates",
makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest {
return codersdk.TemplateInsightsRequest{
TemplateIDs: []uuid.UUID{templates[0].id, templates[1].id, templates[2].id},
StartTime: frozenWeekAgo,
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
Interval: codersdk.InsightsReportIntervalDay,
}
},
},
{
name: "weekly aggregated templates",
makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest {
return codersdk.TemplateInsightsRequest{
TemplateIDs: []uuid.UUID{templates[0].id, templates[1].id, templates[2].id},
StartTime: frozenWeekAgo.AddDate(0, 0, -1),
EndTime: frozenWeekAgo.AddDate(0, 0, 6),
Interval: codersdk.InsightsReportIntervalWeek,
}
},
},
{
name: "week first template",
makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest {
return codersdk.TemplateInsightsRequest{
TemplateIDs: []uuid.UUID{templates[0].id},
StartTime: frozenWeekAgo,
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
Interval: codersdk.InsightsReportIntervalDay,
}
},
},
{
name: "weekly aggregated first template",
makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest {
return codersdk.TemplateInsightsRequest{
TemplateIDs: []uuid.UUID{templates[0].id},
StartTime: frozenWeekAgo,
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
Interval: codersdk.InsightsReportIntervalWeek,
}
},
},
{
name: "week second template",
makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest {
return codersdk.TemplateInsightsRequest{
TemplateIDs: []uuid.UUID{templates[1].id},
StartTime: frozenWeekAgo,
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
Interval: codersdk.InsightsReportIntervalDay,
}
},
},
{
name: "three weeks second template",
makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest {
return codersdk.TemplateInsightsRequest{
TemplateIDs: []uuid.UUID{templates[1].id},
StartTime: frozenWeekAgo.AddDate(0, 0, -14),
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
Interval: codersdk.InsightsReportIntervalWeek,
}
},
},
{
name: "week third template",
makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest {
return codersdk.TemplateInsightsRequest{
TemplateIDs: []uuid.UUID{templates[2].id},
StartTime: frozenWeekAgo,
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
Interval: codersdk.InsightsReportIntervalDay,
}
},
},
{
// São Paulo is three hours behind UTC, so we should not see
// any data between weekAgo and weekAgo.Add(3 * time.Hour).
name: "week other timezone (São Paulo)",
makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest {
return codersdk.TemplateInsightsRequest{
StartTime: frozenWeekAgoSaoPaulo,
EndTime: frozenWeekAgoSaoPaulo.AddDate(0, 0, 7),
Interval: codersdk.InsightsReportIntervalDay,
}
},
},
{
name: "three weeks second template only report",
makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest {
return codersdk.TemplateInsightsRequest{
TemplateIDs: []uuid.UUID{templates[1].id},
StartTime: frozenWeekAgo.AddDate(0, 0, -14),
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
Interval: codersdk.InsightsReportIntervalWeek,
Sections: []codersdk.TemplateInsightsSection{codersdk.TemplateInsightsSectionReport},
}
},
},
{
name: "three weeks second template only interval reports",
makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest {
return codersdk.TemplateInsightsRequest{
TemplateIDs: []uuid.UUID{templates[1].id},
StartTime: frozenWeekAgo.AddDate(0, 0, -14),
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
Interval: codersdk.InsightsReportIntervalWeek,
Sections: []codersdk.TemplateInsightsSection{codersdk.TemplateInsightsSectionIntervalReports},
}
},
},
},
},
{
name: "parameters",
makeFixture: baseTemplateAndUserFixture,
makeTestData: func(templates []*testTemplate, users []*testUser) map[*testWorkspace]testDataGen {
return map[*testWorkspace]testDataGen{}
},
requests: []testRequest{
{
// Since workspaces are created "now", we can only get
// parameters using a time range that includes "now".
// We check yesterday and today for stability just in case
// the test runs at UTC midnight.
name: "yesterday and today deployment wide",
ignoreTimes: true,
makeRequest: func(_ []*testTemplate) codersdk.TemplateInsightsRequest {
now := time.Now().UTC()
return codersdk.TemplateInsightsRequest{
StartTime: now.Truncate(24*time.Hour).AddDate(0, 0, -1),
EndTime: now.Truncate(time.Hour).Add(time.Hour),
}
},
},
{
name: "two days ago, no data",
ignoreTimes: true,
makeRequest: func(_ []*testTemplate) codersdk.TemplateInsightsRequest {
twoDaysAgo := time.Now().UTC().Truncate(24*time.Hour).AddDate(0, 0, -2)
return codersdk.TemplateInsightsRequest{
StartTime: twoDaysAgo,
EndTime: twoDaysAgo.AddDate(0, 0, 1),
}
},
},
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.NotNil(t, tt.makeFixture, "test bug: makeFixture must be set")
require.NotNil(t, tt.makeTestData, "test bug: makeTestData must be set")
templates, users, testData := prepareFixtureAndTestData(t, tt.makeFixture, tt.makeTestData)
client, events := prepare(t, templates, users, testData)
// Drain two events, the first one resumes rolluper
// operation and the second one waits for the rollup
// to complete.
_, _ = <-events, <-events
for _, req := range tt.requests {
req := req
t.Run(req.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
report, err := client.TemplateInsights(ctx, req.makeRequest(templates))
require.NoError(t, err, "want no error getting template insights")
if req.ignoreTimes {
// Ignore times, we're only interested in the data.
report.Report.StartTime = time.Time{}
report.Report.EndTime = time.Time{}
for i := range report.IntervalReports {
report.IntervalReports[i].StartTime = time.Time{}
report.IntervalReports[i].EndTime = time.Time{}
}
}
partialName := strings.Join(strings.Split(t.Name(), "/")[1:], "_")
goldenFile := filepath.Join("testdata", "insights", "template", partialName+".json.golden")
if *updateGoldenFiles {
err = os.MkdirAll(filepath.Dir(goldenFile), 0o755)
require.NoError(t, err, "want no error creating golden file directory")
f, err := os.Create(goldenFile)
require.NoError(t, err, "want no error creating golden file")
defer f.Close()
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
enc.Encode(report)
return
}
f, err := os.Open(goldenFile)
require.NoError(t, err, "open golden file, run \"make gen/golden-files\" and commit the changes")
defer f.Close()
var want codersdk.TemplateInsightsResponse
err = json.NewDecoder(f).Decode(&want)
require.NoError(t, err, "want no error decoding golden file")
cmpOpts := []cmp.Option{
// Ensure readable UUIDs in diff.
cmp.Transformer("UUIDs", func(in []uuid.UUID) (s []string) {
for _, id := range in {
s = append(s, id.String())
}
return s
}),
}
// Use cmp.Diff here because it produces more readable diffs.
assert.Empty(t, cmp.Diff(want, report, cmpOpts...), "golden file mismatch (-want +got): %s, run \"make gen/golden-files\", verify and commit the changes", goldenFile)
})
}
})
}
}
func TestUserActivityInsights_Golden(t *testing.T) {
t.Parallel()
// Prepare test data types.
type templateApp struct {
name string
icon string
}
type testTemplate struct {
name string
apps []templateApp
// Filled later.
id uuid.UUID
}
type workspaceApp templateApp
type testWorkspace struct {
name string
template *testTemplate
// Filled later.
id uuid.UUID
user any // *testUser, but it's not available yet, defined below.
agentID uuid.UUID
apps []*workspaceApp
agentClient *agentsdk.Client
}
type testUser struct {
name string
workspaces []*testWorkspace
client *codersdk.Client
sdk codersdk.User
// Filled later.
id uuid.UUID
}
// Represent agent stats, to be inserted via stats batcher.
type agentStat struct {
// Set a range via start/end, multiple stats will be generated
// within the range.
startedAt time.Time
endedAt time.Time
sessionCountVSCode int64
sessionCountJetBrains int64
sessionCountReconnectingPTY int64
sessionCountSSH int64
noConnections bool
}
// Represent app usage stats, to be inserted via stats reporter.
type appUsage struct {
app *workspaceApp
startedAt time.Time
endedAt time.Time
requests int
}
// Represent actual data being generated on a per-workspace basis.
type testDataGen struct {
agentStats []agentStat
appUsage []appUsage
}
prepareFixtureAndTestData := func(t *testing.T, makeFixture func() ([]*testTemplate, []*testUser), makeData func([]*testTemplate, []*testUser) map[*testWorkspace]testDataGen) ([]*testTemplate, []*testUser, map[*testWorkspace]testDataGen) {
var stableIDs []uuid.UUID
newStableUUID := func() uuid.UUID {
stableIDs = append(stableIDs, uuid.MustParse(fmt.Sprintf("00000000-0000-0000-0000-%012d", len(stableIDs)+1)))
stableID := stableIDs[len(stableIDs)-1]
return stableID
}
templates, users := makeFixture()
for _, template := range templates {
template.id = newStableUUID()
}
for _, user := range users {
user.id = newStableUUID()
}
for _, user := range users {
for _, workspace := range user.workspaces {
workspace.user = user
for _, app := range workspace.template.apps {
app := workspaceApp(app)
workspace.apps = append(workspace.apps, &app)
}
}
}
testData := makeData(templates, users)
// Sanity check.
for ws, data := range testData {
for _, usage := range data.appUsage {
found := false
for _, app := range ws.apps {
if usage.app == app { // Pointer equality
found = true
break
}
}
if !found {
for _, user := range users {
for _, workspace := range user.workspaces {
for _, app := range workspace.apps {
if usage.app == app { // Pointer equality
require.True(t, found, "test bug: app %q not in workspace %q: want user=%s workspace=%s; got user=%s workspace=%s ", usage.app.name, ws.name, ws.user.(*testUser).name, ws.name, user.name, workspace.name)
break
}
}
}
}
require.True(t, found, "test bug: app %q not in workspace %q", usage.app.name, ws.name)
}
}
}
return templates, users, testData
}
prepare := func(t *testing.T, templates []*testTemplate, users []*testUser, testData map[*testWorkspace]testDataGen) (*codersdk.Client, chan dbrollup.Event) {
logger := testutil.Logger(t)
db, ps := dbtestutil.NewDB(t)
events := make(chan dbrollup.Event)
client := coderdtest.New(t, &coderdtest.Options{
Database: db,
Pubsub: ps,
Logger: &logger,
IncludeProvisionerDaemon: true,
AgentStatsRefreshInterval: time.Hour, // Not relevant for this test.
DatabaseRolluper: dbrollup.New(
logger.Named("dbrollup"),
db,
dbrollup.WithInterval(time.Millisecond*50),
dbrollup.WithEventChannel(events),
),
})
firstUser := coderdtest.CreateFirstUser(t, client)
// Prepare all test users.
for _, user := range users {
_ = dbgen.User(t, db, database.User{
ID: user.id,
Username: user.name,
Status: database.UserStatusActive,
})
_ = dbgen.OrganizationMember(t, db, database.OrganizationMember{
UserID: user.id,
OrganizationID: firstUser.OrganizationID,
})
token, err := client.CreateToken(context.Background(), user.id.String(), codersdk.CreateTokenRequest{
Lifetime: time.Hour * 24,
Scope: codersdk.APIKeyScopeAll,
TokenName: "no-password-user-token",
})
require.NoError(t, err)
userClient := codersdk.New(client.URL)
userClient.SetSessionToken(token.Key)
coderUser, err := userClient.User(context.Background(), user.id.String())
require.NoError(t, err)
user.client = userClient
user.sdk = coderUser
user.client.SetLogger(logger.Named("user").With(slog.Field{Name: "name", Value: user.name}))
}
// Prepare all the templates.
for _, template := range templates {
template := template
// Prepare all workspace resources (agents and apps).
var (
createWorkspaces []func(uuid.UUID)
waitWorkspaces []func()
)
var resources []*proto.Resource
for _, user := range users {
user := user
for _, workspace := range user.workspaces {
workspace := workspace
if workspace.template != template {
continue
}
authToken := uuid.New()
agentClient := agentsdk.New(client.URL)
agentClient.SetSessionToken(authToken.String())
workspace.agentClient = agentClient
var apps []*proto.App
for _, app := range workspace.apps {
apps = append(apps, &proto.App{
Slug: app.name,
DisplayName: app.name,
Icon: app.icon,
SharingLevel: proto.AppSharingLevel_OWNER,
Url: "http://",
})
}
resources = append(resources, &proto.Resource{
Name: "example",
Type: "aws_instance",
Agents: []*proto.Agent{{
Id: uuid.NewString(), // Doesn't matter, not used in DB.
Name: "dev",
Auth: &proto.Agent_Token{
Token: authToken.String(),
},
Apps: apps,
}},
})
createWorkspaces = append(createWorkspaces, func(templateID uuid.UUID) {
// Create workspace using the users client.
createdWorkspace := coderdtest.CreateWorkspace(t, user.client, templateID)
workspace.id = createdWorkspace.ID
waitWorkspaces = append(waitWorkspaces, func() {
coderdtest.AwaitWorkspaceBuildJobCompleted(t, user.client, createdWorkspace.LatestBuild.ID)
ctx := testutil.Context(t, testutil.WaitShort)
ws, err := user.client.Workspace(ctx, workspace.id)
require.NoError(t, err, "want no error getting workspace")
workspace.agentID = ws.LatestBuild.Resources[0].Agents[0].ID
})
})
}
}
// Create the template version and template.
version := coderdtest.CreateTemplateVersion(t, client, firstUser.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.PlanComplete,
ProvisionApply: []*proto.Response{{
Type: &proto.Response_Apply{
Apply: &proto.ApplyComplete{
Resources: resources,
},
},
}},
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
// Create template, essentially a modified version of CreateTemplate
// where we can control the template ID.
// createdTemplate := coderdtest.CreateTemplate(t, client, firstUser.OrganizationID, version.ID)
createdTemplate := dbgen.Template(t, db, database.Template{
ID: template.id,
ActiveVersionID: version.ID,
OrganizationID: firstUser.OrganizationID,
CreatedBy: firstUser.UserID,
GroupACL: database.TemplateACL{
firstUser.OrganizationID.String(): db2sdk.TemplateRoleActions(codersdk.TemplateRoleUse),
},
})
err := db.UpdateTemplateVersionByID(context.Background(), database.UpdateTemplateVersionByIDParams{
ID: version.ID,
TemplateID: uuid.NullUUID{
UUID: createdTemplate.ID,
Valid: true,
},
})
require.NoError(t, err, "want no error updating template version")
// Create all workspaces and wait for them.
for _, createWorkspace := range createWorkspaces {
createWorkspace(template.id)
}
for _, waitWorkspace := range waitWorkspaces {
waitWorkspace()
}
}
ctx := testutil.Context(t, testutil.WaitSuperLong)
// Use agent stats batcher to insert agent stats, similar to live system.
// NOTE(mafredri): Ideally we would pass batcher as a coderd option and
// insert using the agentClient, but we have a circular dependency on
// the database.
batcher, batcherCloser, err := workspacestats.NewBatcher(
ctx,
workspacestats.BatcherWithStore(db),
workspacestats.BatcherWithLogger(logger.Named("batchstats")),
workspacestats.BatcherWithInterval(time.Hour),
)
require.NoError(t, err)
defer batcherCloser() // Flushes the stats, this is to ensure they're written.
for workspace, data := range testData {
for _, stat := range data.agentStats {
createdAt := stat.startedAt
connectionCount := int64(1)
if stat.noConnections {
connectionCount = 0
}
for createdAt.Before(stat.endedAt) {
batcher.Add(createdAt, workspace.agentID, workspace.template.id, workspace.user.(*testUser).sdk.ID, workspace.id, &agentproto.Stats{
ConnectionCount: connectionCount,
SessionCountVscode: stat.sessionCountVSCode,
SessionCountJetbrains: stat.sessionCountJetBrains,
SessionCountReconnectingPty: stat.sessionCountReconnectingPTY,
SessionCountSsh: stat.sessionCountSSH,
}, false)
createdAt = createdAt.Add(30 * time.Second)
}
}
}
// Insert app usage.
var stats []workspaceapps.StatsReport
for workspace, data := range testData {
for _, usage := range data.appUsage {
appName := usage.app.name
accessMethod := workspaceapps.AccessMethodPath
if usage.app.name == "terminal" {
appName = ""
accessMethod = workspaceapps.AccessMethodTerminal
}
stats = append(stats, workspaceapps.StatsReport{
UserID: workspace.user.(*testUser).sdk.ID,
WorkspaceID: workspace.id,
AgentID: workspace.agentID,
AccessMethod: accessMethod,
SlugOrPort: appName,
SessionID: uuid.New(),
SessionStartedAt: usage.startedAt,
SessionEndedAt: usage.endedAt,
Requests: usage.requests,
})
}
}
reporter := workspacestats.NewReporter(workspacestats.ReporterOptions{
Database: db,
AppStatBatchSize: workspaceapps.DefaultStatsDBReporterBatchSize,
})
//nolint:gocritic // This is a test.
err = reporter.ReportAppStats(dbauthz.AsSystemRestricted(ctx), stats)
require.NoError(t, err, "want no error inserting app stats")
return client, events
}
baseTemplateAndUserFixture := func() ([]*testTemplate, []*testUser) {
// Test templates and configuration to generate.
templates := []*testTemplate{
// Create two templates with near-identical apps and parameters
// to allow testing for grouping similar data.
{
name: "template1",
apps: []templateApp{
{name: "app1", icon: "/icon1.png"},
{name: "app2", icon: "/icon2.png"},
{name: "app3", icon: "/icon2.png"},
},
},
{
name: "template2",
apps: []templateApp{
{name: "app1", icon: "/icon1.png"},
{name: "app2", icon: "/icon2.png"},
{name: "app3", icon: "/icon2.png"},
},
},
// Create another template with different parameters and apps.
{
name: "othertemplate",
apps: []templateApp{
{name: "otherapp1", icon: "/icon1.png"},
// This "special test app" will be converted into web
// terminal usage, this is not included in stats since we
// currently rely on agent stats for this data.
{name: "terminal", icon: "/terminal.png"},
},
},
}
// Users and workspaces to generate.
users := []*testUser{
{
name: "user1",
workspaces: []*testWorkspace{
{
name: "workspace1",
template: templates[0],
},
{
name: "workspace2",
template: templates[1],
},
{
name: "otherworkspace3",
template: templates[2],
},
},
},
{
name: "user2",
workspaces: []*testWorkspace{
{
name: "workspace1",
template: templates[0],
},
},
},
{
name: "user3",
workspaces: []*testWorkspace{
{
name: "otherworkspace1",
template: templates[2],
},
{
name: "workspace2",
template: templates[0],
},
},
},
}
return templates, users
}
// Time range for report, test data will be generated within and
// outside this range, but only data within the range should be
// included in the report.
frozenLastNight := time.Date(2023, 8, 22, 0, 0, 0, 0, time.UTC)
frozenWeekAgo := frozenLastNight.AddDate(0, 0, -7)
saoPaulo, err := time.LoadLocation("America/Sao_Paulo")
require.NoError(t, err)
frozenWeekAgoSaoPaulo, err := time.ParseInLocation(time.DateTime, frozenWeekAgo.Format(time.DateTime), saoPaulo)
require.NoError(t, err)
//nolint:dupl // For testing purposes
makeBaseTestData := func(templates []*testTemplate, users []*testUser) map[*testWorkspace]testDataGen {
return map[*testWorkspace]testDataGen{
users[0].workspaces[0]: {
agentStats: []agentStat{
{ // One hour of usage.
startedAt: frozenWeekAgo,
endedAt: frozenWeekAgo.Add(time.Hour),
sessionCountVSCode: 1,
sessionCountSSH: 1,
},
{ // 12 minutes of usage.
startedAt: frozenWeekAgo.AddDate(0, 0, 1),
endedAt: frozenWeekAgo.AddDate(0, 0, 1).Add(12 * time.Minute),
sessionCountSSH: 1,
},
{ // 1m30s of usage -> 2m rounded.
startedAt: frozenWeekAgo.AddDate(0, 0, 2).Add(4*time.Minute + 30*time.Second),
endedAt: frozenWeekAgo.AddDate(0, 0, 2).Add(6 * time.Minute),
sessionCountJetBrains: 1,
},
},
appUsage: []appUsage{
{ // One hour of usage.
app: users[0].workspaces[0].apps[0],
startedAt: frozenWeekAgo,
endedAt: frozenWeekAgo.Add(time.Hour),
requests: 1,
},
{ // 30s of app usage -> 1m rounded.
app: users[0].workspaces[0].apps[0],
startedAt: frozenWeekAgo.Add(2*time.Hour + 10*time.Second),
endedAt: frozenWeekAgo.Add(2*time.Hour + 40*time.Second),
requests: 1,
},
{ // 1m30s of app usage -> 2m rounded (included in São Paulo).
app: users[0].workspaces[0].apps[0],
startedAt: frozenWeekAgo.Add(3*time.Hour + 30*time.Second),
endedAt: frozenWeekAgo.Add(3*time.Hour + 90*time.Second),
requests: 1,
},
{ // used an app on the last day, counts as active user, 12m.
app: users[0].workspaces[0].apps[2],
startedAt: frozenWeekAgo.AddDate(0, 0, 6),
endedAt: frozenWeekAgo.AddDate(0, 0, 6).Add(12 * time.Minute),
requests: 1,
},
},
},
users[0].workspaces[1]: {
agentStats: []agentStat{
{
// One hour of usage in second template at the same time
// as in first template. When selecting both templates
// this user and their app usage will only be counted
// once but the template ID will show up in the data.
startedAt: frozenWeekAgo,
endedAt: frozenWeekAgo.Add(time.Hour),
sessionCountVSCode: 1,
sessionCountSSH: 1,
},
{ // One hour of usage.
startedAt: frozenWeekAgo.AddDate(0, 0, -12),
endedAt: frozenWeekAgo.AddDate(0, 0, -12).Add(time.Hour),
sessionCountSSH: 1,
sessionCountReconnectingPTY: 1,
},
{ // Another one hour of usage, but "active users" shouldn't be increased twice.
startedAt: frozenWeekAgo.AddDate(0, 0, -10),
endedAt: frozenWeekAgo.AddDate(0, 0, -10).Add(time.Hour),
sessionCountSSH: 1,
sessionCountReconnectingPTY: 1,
},
},
appUsage: []appUsage{
{ // One hour of usage, but same user and same template app, only count once.
app: users[0].workspaces[1].apps[0],
startedAt: frozenWeekAgo,
endedAt: frozenWeekAgo.Add(time.Hour),
requests: 1,
},
{
// Different templates but identical apps, apps will be
// combined and usage will be summed.
app: users[0].workspaces[1].apps[0],
startedAt: frozenWeekAgo.AddDate(0, 0, 2),
endedAt: frozenWeekAgo.AddDate(0, 0, 2).Add(6 * time.Hour),
requests: 1,
},
},
},
users[0].workspaces[2]: {
agentStats: []agentStat{},
appUsage: []appUsage{},
},
users[1].workspaces[0]: {
agentStats: []agentStat{
{ // One hour of agent usage before timeframe (exclude).
startedAt: frozenWeekAgo.Add(-time.Hour),
endedAt: frozenWeekAgo,
sessionCountVSCode: 1,
sessionCountSSH: 1,
},
{ // One hour of usage.
startedAt: frozenWeekAgo,
endedAt: frozenWeekAgo.Add(time.Hour),
sessionCountSSH: 1,
},
{ // One hour of agent usage after timeframe (exclude in UTC, include in São Paulo).
startedAt: frozenWeekAgo.AddDate(0, 0, 7),
endedAt: frozenWeekAgo.AddDate(0, 0, 7).Add(time.Hour),
sessionCountVSCode: 1,
sessionCountSSH: 1,
},
},
appUsage: []appUsage{
{ // One hour of app usage before timeframe (exclude).
app: users[1].workspaces[0].apps[2],
startedAt: frozenWeekAgo.Add(-time.Hour),
endedAt: frozenWeekAgo,
requests: 1,
},
{ // One hour of app usage after timeframe (exclude in UTC, include in São Paulo).
app: users[1].workspaces[0].apps[2],
startedAt: frozenWeekAgo.AddDate(0, 0, 7),
endedAt: frozenWeekAgo.AddDate(0, 0, 7).Add(time.Hour),
requests: 1,
},
},
},
users[2].workspaces[0]: {
agentStats: []agentStat{
{ // One hour of usage.
startedAt: frozenWeekAgo,
endedAt: frozenWeekAgo.Add(time.Hour),
sessionCountSSH: 1,
sessionCountReconnectingPTY: 1,
},
},
appUsage: []appUsage{
{
app: users[2].workspaces[0].apps[0],
startedAt: frozenWeekAgo.AddDate(0, 0, 2),
endedAt: frozenWeekAgo.AddDate(0, 0, 2).Add(5 * time.Minute),
requests: 1,
},
{ // Special app; excluded from apps, but counted as active during the day.
app: users[2].workspaces[0].apps[1],
startedAt: frozenWeekAgo.AddDate(0, 0, 3),
endedAt: frozenWeekAgo.AddDate(0, 0, 3).Add(5 * time.Minute),
requests: 1,
},
},
},
}
}
type testRequest struct {
name string
makeRequest func([]*testTemplate) codersdk.UserActivityInsightsRequest
ignoreTimes bool
}
tests := []struct {
name string
makeFixture func() ([]*testTemplate, []*testUser)
makeTestData func([]*testTemplate, []*testUser) map[*testWorkspace]testDataGen
requests []testRequest
}{
{
name: "multiple users and workspaces",
makeFixture: baseTemplateAndUserFixture,
makeTestData: makeBaseTestData,
requests: []testRequest{
{
name: "week deployment wide",
makeRequest: func(templates []*testTemplate) codersdk.UserActivityInsightsRequest {
return codersdk.UserActivityInsightsRequest{
StartTime: frozenWeekAgo,
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
}
},
},
{
name: "weekly aggregated deployment wide",
makeRequest: func(templates []*testTemplate) codersdk.UserActivityInsightsRequest {
return codersdk.UserActivityInsightsRequest{
StartTime: frozenWeekAgo.AddDate(0, 0, -3),
EndTime: frozenWeekAgo.AddDate(0, 0, 4),
}
},
},
{
name: "week all templates",
makeRequest: func(templates []*testTemplate) codersdk.UserActivityInsightsRequest {
return codersdk.UserActivityInsightsRequest{
TemplateIDs: []uuid.UUID{templates[0].id, templates[1].id, templates[2].id},
StartTime: frozenWeekAgo,
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
}
},
},
{
name: "weekly aggregated templates",
makeRequest: func(templates []*testTemplate) codersdk.UserActivityInsightsRequest {
return codersdk.UserActivityInsightsRequest{
TemplateIDs: []uuid.UUID{templates[0].id, templates[1].id, templates[2].id},
StartTime: frozenWeekAgo.AddDate(0, 0, -1),
EndTime: frozenWeekAgo.AddDate(0, 0, 6),
}
},
},
{
name: "week first template",
makeRequest: func(templates []*testTemplate) codersdk.UserActivityInsightsRequest {
return codersdk.UserActivityInsightsRequest{
TemplateIDs: []uuid.UUID{templates[0].id},
StartTime: frozenWeekAgo,
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
}
},
},
{
name: "weekly aggregated first template",
makeRequest: func(templates []*testTemplate) codersdk.UserActivityInsightsRequest {
return codersdk.UserActivityInsightsRequest{
TemplateIDs: []uuid.UUID{templates[0].id},
StartTime: frozenWeekAgo,
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
}
},
},
{
name: "week second template",
makeRequest: func(templates []*testTemplate) codersdk.UserActivityInsightsRequest {
return codersdk.UserActivityInsightsRequest{
TemplateIDs: []uuid.UUID{templates[1].id},
StartTime: frozenWeekAgo,
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
}
},
},
{
name: "three weeks second template",
makeRequest: func(templates []*testTemplate) codersdk.UserActivityInsightsRequest {
return codersdk.UserActivityInsightsRequest{
TemplateIDs: []uuid.UUID{templates[1].id},
StartTime: frozenWeekAgo.AddDate(0, 0, -14),
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
}
},
},
{
name: "week third template",
makeRequest: func(templates []*testTemplate) codersdk.UserActivityInsightsRequest {
return codersdk.UserActivityInsightsRequest{
TemplateIDs: []uuid.UUID{templates[2].id},
StartTime: frozenWeekAgo,
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
}
},
},
{
// São Paulo is three hours behind UTC, so we should not see
// any data between weekAgo and weekAgo.Add(3 * time.Hour).
name: "week other timezone (São Paulo)",
makeRequest: func(templates []*testTemplate) codersdk.UserActivityInsightsRequest {
return codersdk.UserActivityInsightsRequest{
StartTime: frozenWeekAgoSaoPaulo,
EndTime: frozenWeekAgoSaoPaulo.AddDate(0, 0, 7),
}
},
},
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.NotNil(t, tt.makeFixture, "test bug: makeFixture must be set")
require.NotNil(t, tt.makeTestData, "test bug: makeTestData must be set")
templates, users, testData := prepareFixtureAndTestData(t, tt.makeFixture, tt.makeTestData)
client, events := prepare(t, templates, users, testData)
// Drain two events, the first one resumes rolluper
// operation and the second one waits for the rollup
// to complete.
_, _ = <-events, <-events
for _, req := range tt.requests {
req := req
t.Run(req.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
report, err := client.UserActivityInsights(ctx, req.makeRequest(templates))
require.NoError(t, err, "want no error getting template insights")
if req.ignoreTimes {
// Ignore times, we're only interested in the data.
report.Report.StartTime = time.Time{}
report.Report.EndTime = time.Time{}
}
partialName := strings.Join(strings.Split(t.Name(), "/")[1:], "_")
goldenFile := filepath.Join("testdata", "insights", "user-activity", partialName+".json.golden")
if *updateGoldenFiles {
err = os.MkdirAll(filepath.Dir(goldenFile), 0o755)
require.NoError(t, err, "want no error creating golden file directory")
f, err := os.Create(goldenFile)
require.NoError(t, err, "want no error creating golden file")
defer f.Close()
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
enc.Encode(report)
return
}
f, err := os.Open(goldenFile)
require.NoError(t, err, "open golden file, run \"make gen/golden-files\" and commit the changes")
defer f.Close()
var want codersdk.UserActivityInsightsResponse
err = json.NewDecoder(f).Decode(&want)
require.NoError(t, err, "want no error decoding golden file")
cmpOpts := []cmp.Option{
// Ensure readable UUIDs in diff.
cmp.Transformer("UUIDs", func(in []uuid.UUID) (s []string) {
for _, id := range in {
s = append(s, id.String())
}
return s
}),
}
// Use cmp.Diff here because it produces more readable diffs.
assert.Empty(t, cmp.Diff(want, report, cmpOpts...), "golden file mismatch (-want +got): %s, run \"make gen/golden-files\", verify and commit the changes", goldenFile)
})
}
})
}
}
func TestTemplateInsights_BadRequest(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{})
_ = coderdtest.CreateFirstUser(t, client)
y, m, d := time.Now().UTC().Date()
today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
_, err := client.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{
StartTime: today,
EndTime: today.AddDate(0, 0, -1),
})
assert.Error(t, err, "want error for end time before start time")
_, err = client.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{
StartTime: today.AddDate(0, 0, -1),
EndTime: today,
Interval: "invalid",
})
assert.Error(t, err, "want error for bad interval")
_, err = client.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{
StartTime: today.AddDate(0, 0, -5),
EndTime: today,
Interval: codersdk.InsightsReportIntervalWeek,
})
assert.Error(t, err, "last report interval must have at least 6 days")
_, err = client.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{
StartTime: today.AddDate(0, 0, -1),
EndTime: today,
Interval: codersdk.InsightsReportIntervalWeek,
Sections: []codersdk.TemplateInsightsSection{"invalid"},
})
assert.Error(t, err, "want error for bad section")
}
func TestTemplateInsights_RBAC(t *testing.T) {
t.Parallel()
y, m, d := time.Now().UTC().Date()
today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
type test struct {
interval codersdk.InsightsReportInterval
withTemplate bool
}
tests := []test{
{codersdk.InsightsReportIntervalDay, true},
{codersdk.InsightsReportIntervalDay, false},
{"", true},
{"", false},
}
for _, tt := range tests {
tt := tt
t.Run(fmt.Sprintf("with interval=%q", tt.interval), func(t *testing.T) {
t.Parallel()
t.Run("AsOwner", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
var templateIDs []uuid.UUID
if tt.withTemplate {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
templateIDs = append(templateIDs, template.ID)
}
_, err := client.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{
StartTime: today.AddDate(0, 0, -1),
EndTime: today,
Interval: tt.interval,
TemplateIDs: templateIDs,
})
require.NoError(t, err)
})
t.Run("AsTemplateAdmin", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
var templateIDs []uuid.UUID
if tt.withTemplate {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
templateIDs = append(templateIDs, template.ID)
}
_, err := templateAdmin.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{
StartTime: today.AddDate(0, 0, -1),
EndTime: today,
Interval: tt.interval,
TemplateIDs: templateIDs,
})
require.NoError(t, err)
})
t.Run("AsRegularUser", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
regular, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
var templateIDs []uuid.UUID
if tt.withTemplate {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
templateIDs = append(templateIDs, template.ID)
}
_, err := regular.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{
StartTime: today.AddDate(0, 0, -1),
EndTime: today,
Interval: tt.interval,
TemplateIDs: templateIDs,
})
require.Error(t, err)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
})
})
}
}
func TestGenericInsights_RBAC(t *testing.T) {
t.Parallel()
y, m, d := time.Now().UTC().Date()
today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
type fetchInsightsFunc func(ctx context.Context, client *codersdk.Client, startTime, endTime time.Time, templateIDs ...uuid.UUID) error
type test struct {
withTemplate bool
}
endpoints := map[string]fetchInsightsFunc{
"UserLatency": func(ctx context.Context, client *codersdk.Client, startTime, endTime time.Time, templateIDs ...uuid.UUID) error {
_, err := client.UserLatencyInsights(ctx, codersdk.UserLatencyInsightsRequest{
StartTime: startTime,
EndTime: endTime,
TemplateIDs: templateIDs,
})
return err
},
"UserActivity": func(ctx context.Context, client *codersdk.Client, startTime, endTime time.Time, templateIDs ...uuid.UUID) error {
_, err := client.UserActivityInsights(ctx, codersdk.UserActivityInsightsRequest{
StartTime: startTime,
EndTime: endTime,
TemplateIDs: templateIDs,
})
return err
},
}
for endpointName, endpoint := range endpoints {
endpointName := endpointName
endpoint := endpoint
t.Run(fmt.Sprintf("With%sEndpoint", endpointName), func(t *testing.T) {
t.Parallel()
tests := []test{
{true},
{false},
}
for _, tt := range tests {
tt := tt
t.Run("AsOwner", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
var templateIDs []uuid.UUID
if tt.withTemplate {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
templateIDs = append(templateIDs, template.ID)
}
err := endpoint(ctx, client,
today,
time.Now().UTC().Truncate(time.Hour).Add(time.Hour), // Round up to include the current hour.
templateIDs...)
require.NoError(t, err)
})
t.Run("AsTemplateAdmin", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
var templateIDs []uuid.UUID
if tt.withTemplate {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
templateIDs = append(templateIDs, template.ID)
}
err := endpoint(ctx, templateAdmin,
today,
time.Now().UTC().Truncate(time.Hour).Add(time.Hour), // Round up to include the current hour.
templateIDs...)
require.NoError(t, err)
})
t.Run("AsRegularUser", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
regular, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
var templateIDs []uuid.UUID
if tt.withTemplate {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
templateIDs = append(templateIDs, template.ID)
}
err := endpoint(ctx, regular,
today,
time.Now().UTC().Truncate(time.Hour).Add(time.Hour), // Round up to include the current hour.
templateIDs...)
require.Error(t, err)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
})
}
})
}
}