Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 58 additions & 44 deletions coderd/database/dbauthz/dbauthz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"database/sql"
"encoding/json"
"fmt"
"maps"
"net"
"reflect"
"strconv"
Expand Down Expand Up @@ -7950,10 +7949,11 @@ func TestAsExternalAuthChecker(t *testing.T) {
})
}

// TestSessionCountAppFamiliesRequired ensures the queries that take the app
// family registry fail loudly when it is empty, so a forgotten parameter
// surfaces as an error instead of silently dropping every family's sessions
// from usage reporting.
// TestSessionCountAppFamiliesRequired ensures the queries that take the
// app-to-family registry fail loudly when it is missing. The queries fall
// back to the unknown family, so a forgotten parameter would misattribute
// known activity and undercount the fixed per-family compatibility fields
// rather than surfacing an error.
func TestSessionCountAppFamiliesRequired(t *testing.T) {
t.Parallel()

Expand All @@ -7965,50 +7965,37 @@ func TestSessionCountAppFamiliesRequired(t *testing.T) {
ctx := dbauthz.As(context.Background(), coderdtest.RandomRBACSubject())

_, err := q.GetTemplateInsightsByTemplate(ctx, database.GetTemplateInsightsByTemplateParams{})
require.ErrorContains(t, err, "developer error")
require.ErrorContains(t, err, "app family registry is empty")
err = q.UpsertTemplateUsageStats(ctx, nil)
require.ErrorContains(t, err, "developer error")
require.ErrorContains(t, err, "app family registry is empty")
}

// TestSessionCountAppFamiliesMustMatchQueries covers registries that are
// present but wrong. Each query hardcodes one probe per family, so a registry
// whose keys drifted from codersdk.AttributedAppFamilies would report zero
// for the affected family instead of failing.
func TestSessionCountAppFamiliesMustMatchQueries(t *testing.T) {
// TestSessionCountAppFamiliesShape covers registries that are present but
// unusable. The queries join the registry by normalized app name and fall
// back to the unknown family, so an entry the join can never match, or one
// with no family to attribute to, would misattribute known activity and
// report a plausible but undercounted result instead of failing.
func TestSessionCountAppFamiliesShape(t *testing.T) {
t.Parallel()

valid := map[codersdk.AppFamilyName][]string{}
for _, family := range codersdk.AttributedAppFamilies() {
valid[family] = []string{string(family)}
}
without := func(drop codersdk.AppFamilyName) json.RawMessage {
families := maps.Clone(valid)
delete(families, drop)
return mustMarshalAppFamilies(t, families)
}

for _, tc := range []struct {
name string
appFamilies json.RawMessage
errContains string
}{
{"EmptyObject", json.RawMessage(`{}`), `missing family "vscode"`},
{"JSONNull", json.RawMessage(`null`), `missing family "vscode"`},
{"NotAnObject", json.RawMessage(`["vscode"]`), "must be a JSON object"},
{"MissingFamily", without(codersdk.AppFamilySSH), `missing family "ssh"`},
{"EmptyAppNames", mustMarshalAppFamilies(t, map[codersdk.AppFamilyName][]string{
codersdk.AppFamilyVSCode: {"vscode"},
codersdk.AppFamilyJetBrains: {"jetbrains"},
codersdk.AppFamilySSH: {},
codersdk.AppFamilyReconnectingPTY: {"reconnecting_pty"},
}), `no app names for family "ssh"`},
{"UnknownFamily", mustMarshalAppFamilies(t, map[codersdk.AppFamilyName][]string{
codersdk.AppFamilyVSCode: {"vscode"},
codersdk.AppFamilyJetBrains: {"jetbrains"},
codersdk.AppFamilySSH: {"ssh"},
codersdk.AppFamilyReconnectingPTY: {"reconnecting_pty"},
"emacs": {"emacs"},
}), `has family "emacs"`},
{"EmptyObject", json.RawMessage(`{}`), "app family registry is empty"},
{"JSONNull", json.RawMessage(`null`), "app family registry is empty"},
{"NotAnObject", json.RawMessage(`["vscode"]`), "invalid app family registry"},
{"FamilyToAppNames", json.RawMessage(`{"vscode":["cursor"]}`), "invalid app family registry"},
{"UnnormalizedAppName", json.RawMessage(`{"VSCode-Insiders":"vscode"}`), "not normalized"},
{"UnnormalizedFamily", json.RawMessage(`{"vscode":"VS Code"}`), "not normalized"},
{"HyphenatedFamily", json.RawMessage(`{"vscode":"vs-code"}`), "not normalized"},
{"PaddedFamily", json.RawMessage(`{"vscode":" vscode "}`), "not normalized"},
{"UnknownFamily", json.RawMessage(`{"vscode":"unknown"}`), `app "vscode" maps to unknown family`},
{"EmptyAppName", json.RawMessage(`{"":"vscode"}`), "empty app name"},
{"EmptyFamily", json.RawMessage(`{"vscode":""}`), `no family for app "vscode"`},
{"WhitespaceFamily", json.RawMessage(`{"vscode":" "}`), `no family for app "vscode"`},
{"NullFamily", json.RawMessage(`{"vscode":null}`), `no family for app "vscode"`},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
Expand All @@ -8028,9 +8015,36 @@ func TestSessionCountAppFamiliesMustMatchQueries(t *testing.T) {
}
}

func mustMarshalAppFamilies(t *testing.T, families map[codersdk.AppFamilyName][]string) json.RawMessage {
t.Helper()
raw, err := json.Marshal(families)
require.NoError(t, err)
return raw
// No query names a family, so registering an app under a family that has
// never been seen before is valid without any SQL change. Validation must not
// reintroduce a hardcoded family list.
func TestSessionCountAppFamiliesAcceptsNewFamily(t *testing.T) {
t.Parallel()

for _, tc := range []struct {
name string
appFamilies json.RawMessage
}{
{"Registry", codersdk.SessionCountAppFamiliesJSON()},
{"NewFamily", json.RawMessage(`{"emacs":"emacs","vscode":"vscode"}`)},
{"SingleEntry", json.RawMessage(`{"ssh":"ssh"}`)},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

ctrl := gomock.NewController(t)
defer ctrl.Finish()
dbm := dbmock.NewMockStore(ctrl)
dbm.EXPECT().Wrappers().Return([]string{}).AnyTimes()
arg := database.GetTemplateInsightsByTemplateParams{AppFamilies: tc.appFamilies}
dbm.EXPECT().GetTemplateInsightsByTemplate(gomock.Any(), arg).Return([]database.GetTemplateInsightsByTemplateRow{}, nil).AnyTimes()
dbm.EXPECT().UpsertTemplateUsageStats(gomock.Any(), tc.appFamilies).Return(nil).AnyTimes()
q := dbauthz.New(dbm, &coderdtest.RecordingAuthorizer{Wrapped: &coderdtest.FakeAuthorizer{}}, slog.Make(), coderdtest.AccessControlStorePointer())
ctx := dbauthz.As(context.Background(), coderdtest.RandomRBACSubject())

_, err := q.GetTemplateInsightsByTemplate(ctx, arg)
require.NoError(t, err)
require.NoError(t, q.UpsertTemplateUsageStats(ctx, tc.appFamilies))
})
}
}
65 changes: 35 additions & 30 deletions coderd/database/dbauthz/sessioncountparams.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package dbauthz
import (
"context"
"encoding/json"
"slices"
"strings"

"golang.org/x/xerrors"

Expand All @@ -13,42 +13,47 @@ import (
"github.com/coder/coder/v2/codersdk"
)

// The template insights read query and the usage stats rollup take the app
// family attribution registry as a single jsonb parameter. Both hardcode one
// probe per family, so a registry that is empty, malformed, or keyed
// differently from codersdk.AttributedAppFamilies would still run and return
// zero counts for the affected families, silently dropping sessions from
// insights and Prometheus. dbauthz wraps every production store, including
// the transaction stores used by the rollup, so validating here makes a wrong
// registry fail the call loudly instead of failing the data quietly. These
// methods override the generated ones in dbauthz.go; scripts/dbgen preserves
// methods defined outside that file.
// The minute aggregation queries take the app-to-family registry as jsonb and
// fall back to the unknown family for unregistered apps, so a bad registry
// still succeeds while silently misattributing usage. dbauthz wraps every
// production store, including the rollup's transaction stores, so validating
// here fails the call loudly instead. These methods override the generated
// ones in dbauthz.go; scripts/dbgen preserves methods defined outside that
// file.

// validateSessionCountAppFamilies checks that the registry has exactly the
// families the queries probe, each with at least one app name.
// validateSessionCountAppFamilies checks that the registry is a non-empty
// jsonb object of normalized app names to normalized, non-unknown family
// names. New families are valid without any SQL change.
func validateSessionCountAppFamilies(appFamilies json.RawMessage) error {
if len(appFamilies) == 0 {
return xerrors.New("developer error: session count app families must not be empty, populate them with codersdk.SessionCountAppFamiliesJSON()")
var families map[string]codersdk.AppFamilyName
if len(appFamilies) > 0 {
if err := json.Unmarshal(appFamilies, &families); err != nil {
return xerrors.Errorf("invalid app family registry: %w", err)
}
}

var families map[codersdk.AppFamilyName][]string
if err := json.Unmarshal(appFamilies, &families); err != nil {
return xerrors.Errorf("developer error: session count app families must be a JSON object of family to app names, populate them with codersdk.SessionCountAppFamiliesJSON(): %w", err)
if len(families) == 0 {
return xerrors.New("app family registry is empty")
}

required := codersdk.AttributedAppFamilies()
for _, family := range required {
appNames, ok := families[family]
if !ok {
return xerrors.Errorf("developer error: session count app families is missing family %q, which the queries probe; populate them with codersdk.SessionCountAppFamiliesJSON()", family)
for appName, family := range families {
if appName == "" {
return xerrors.New("empty app name")
}
if len(appNames) == 0 {
return xerrors.Errorf("developer error: session count app families has no app names for family %q, so its sessions would go uncounted", family)
// Stored app names are normalized, so an unnormalized key matches no
// session and that app's activity falls back to the unknown family.
if normalized := codersdk.NormalizeAppName(appName); normalized != appName {
return xerrors.Errorf("app name %q not normalized, want %q", appName, normalized)
}
}
for family := range families {
if !slices.Contains(required, family) {
return xerrors.Errorf("developer error: session count app families has family %q, which no query probes; add a probe per query or drop it from codersdk.AttributedAppFamilies", family)
// A blank family is not an attribution, so its apps would report under
// no usable name at all.
if strings.TrimSpace(string(family)) == "" {
return xerrors.Errorf("no family for app %q", appName)
}
if normalized := codersdk.NormalizeAppName(string(family)); normalized != string(family) {
return xerrors.Errorf("family %q for app %q not normalized, want %q", family, appName, normalized)
}
if family == codersdk.AppFamilyUnknown {
return xerrors.Errorf("app %q maps to unknown family", appName)
}
}
return nil
Expand Down
35 changes: 27 additions & 8 deletions coderd/database/dbrollup/dbrollup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ func TestRollup_TwoInstancesUseLocking(t *testing.T) {
func TestRollupTemplateUsageStats(t *testing.T) {
t.Parallel()

db, ps := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure())
db, ps, sqlDB := dbtestutil.NewDBWithSQLDB(t, dbtestutil.WithDumpOnFailure())
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)

anHourAgo := dbtime.Now().Add(-time.Hour).Truncate(time.Hour).UTC()
Expand Down Expand Up @@ -244,16 +244,35 @@ func TestRollupTemplateUsageStats(t *testing.T) {
stats[0].EndTime = stats[0].EndTime.UTC()
stats[0].StartTime = stats[0].StartTime.UTC()

// The digest value isn't pinned, just that the rollup recorded one.
require.True(t, stats[0].SessionUsageDigest.Valid, "the rollup must record a session usage digest")
require.NotZero(t, stats[0].SessionUsageDigest.Int64)
stats[0].SessionUsageDigest = sql.NullInt64{}

require.Equal(t, database.TemplateUsageStat{
TemplateID: tpl.ID,
UserID: user.ID,
StartTime: wags1.CreatedAt,
EndTime: wags1.CreatedAt.Add(30 * time.Minute),
MedianLatencyMs: sql.NullFloat64{Float64: 1, Valid: true},
UsageMins: 3,
ReconnectingPtyMins: 2,
TemplateID: tpl.ID,
UserID: user.ID,
StartTime: wags1.CreatedAt,
EndTime: wags1.CreatedAt.Add(30 * time.Minute),
MedianLatencyMs: sql.NullFloat64{Float64: 1, Valid: true},
UsageMins: 3,
AppUsageMins: database.StringMapOfInt{
app.Slug: 2,
},
}, stats[0])

// Session minutes live in the child tables, keyed by app name and family.
for _, tc := range []struct {
table, nameColumn, name string
}{
{"template_usage_stats_session_apps", "app_name", "reconnecting_pty"},
{"template_usage_stats_session_families", "family", "reconnecting_pty"},
} {
var usageMins int64
//nolint:gosec // Table and column names are constants in this test.
err := sqlDB.QueryRowContext(ctx, "SELECT usage_mins FROM "+tc.table+" WHERE start_time = $1 AND template_id = $2 AND user_id = $3 AND "+tc.nameColumn+" = $4",
wags1.CreatedAt, tpl.ID, user.ID, tc.name).Scan(&usageMins)
require.NoError(t, err, tc.table)
require.EqualValues(t, 2, usageMins, tc.table)
}
}
52 changes: 40 additions & 12 deletions coderd/database/dump.sql

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading