Files
coder/coderd/experiments/experiments_test.go
T

594 lines
21 KiB
Go

package experiments_test
import (
"context"
"fmt"
"strings"
"sync"
"sync/atomic"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/experiments"
"github.com/coder/coder/v2/coderd/experiments/experimentstest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
const (
scoped = codersdk.ExperimentExample
unscoped = codersdk.ExperimentWorkspaceUsage
sentinel = "sentinel-7f3a@example.test"
)
// countingStore records how often each Store method runs.
type countingStore struct {
experiments.Store
rules atomic.Int64
users atomic.Int64
}
func (s *countingStore) Rules(ctx context.Context) (map[codersdk.Experiment]experiments.StoredRule, error) {
s.rules.Add(1)
return s.Store.Rules(ctx)
}
func (s *countingStore) UserAttributes(ctx context.Context, userID uuid.UUID) (experiments.User, error) {
s.users.Add(1)
return s.Store.UserAttributes(ctx, userID)
}
var testUser = experiments.User{
ID: "6d8e1a52-6f2c-4a4e-9b3a-0d1d8f1f7f10",
Username: "alice",
Email: "Alice@Example.com",
Roles: []string{"owner"},
Organizations: []string{"coder", "acme"},
Groups: []string{"coder/Everyone", "coder/beta", "acme/Everyone"},
}
func storeWith(t *testing.T, userID uuid.UUID, rule *experiments.Rule) *countingStore {
t.Helper()
s := experimentstest.Store{Users: map[uuid.UUID]experiments.User{userID: testUser}}
if rule != nil {
s.StoredRules = map[codersdk.Experiment]experiments.StoredRule{
scoped: experimentstest.StoredRule(t, *rule),
}
}
return &countingStore{Store: s}
}
func rawStore(userID uuid.UUID, raw string) *countingStore {
return &countingStore{Store: experimentstest.Store{
Users: map[uuid.UUID]experiments.User{userID: testUser},
StoredRules: map[codersdk.Experiment]experiments.StoredRule{scoped: {Value: []byte(raw)}},
}}
}
func newEvaluator(t *testing.T, store experiments.Store, static codersdk.Experiments) (*experiments.Evaluator, *testutil.FakeSink) {
t.Helper()
sink := testutil.NewFakeSink(t)
e, err := experiments.New(sink.Logger(), store, static)
require.NoError(t, err)
return e, sink
}
func condition(src string) *experiments.Rule {
return &experiments.Rule{Mode: experiments.ModeCondition, Condition: src, Revision: 3}
}
// requireLogsExclude fails if any captured entry contains one of the
// forbidden strings in its message or fields.
func requireLogsExclude(t *testing.T, sink *testutil.FakeSink, forbidden ...string) {
t.Helper()
for _, entry := range sink.Entries() {
text := entry.Message + " " + fmt.Sprint(entry.Fields)
for _, f := range forbidden {
require.NotContains(t, text, f)
}
}
}
// requireLogField fails unless some captured entry has field name set to
// want.
func requireLogField(t *testing.T, sink *testutil.FakeSink, name string, want any) {
t.Helper()
for _, entry := range sink.Entries() {
for _, field := range entry.Fields {
if field.Name == name && fmt.Sprint(field.Value) == fmt.Sprint(want) {
return
}
}
}
require.Failf(t, "missing log field", "%s=%v", name, want)
}
func TestNew(t *testing.T) {
t.Parallel()
_, err := experiments.New(slog.Make(), nil, nil)
require.Error(t, err)
}
func TestValidateRule(t *testing.T) {
t.Parallel()
valid := []experiments.Rule{
{Mode: experiments.ModeInherit},
{Mode: experiments.ModeOn},
{Mode: experiments.ModeOff},
{Mode: experiments.ModeCondition, Condition: `user.email == "a@b.c" || "admin" in user.roles`},
{Mode: experiments.ModeCondition, Condition: strings.Repeat(" ", experiments.MaxConditionSize-4) + "true"},
}
for _, rule := range valid {
require.NoError(t, experiments.ValidateRule(scoped, rule), "mode %q", rule.Mode)
}
invalid := map[string]experiments.Rule{
"syntax": {Mode: experiments.ModeCondition, Condition: `user.email ==`},
"non-bool": {Mode: experiments.ModeCondition, Condition: `user.email`},
"unknown field": {Mode: experiments.ModeCondition, Condition: `user.nickname == "x"`},
"unknown var": {Mode: experiments.ModeCondition, Condition: `request.ip == "x"`},
"size": {Mode: experiments.ModeCondition, Condition: strings.Repeat(" ", experiments.MaxConditionSize-3) + "true"},
"empty": {Mode: experiments.ModeCondition},
"unknown mode": {Mode: "sometimes"},
"stray condition": {Mode: experiments.ModeOn, Condition: "true"},
}
for name, rule := range invalid {
require.Error(t, experiments.ValidateRule(scoped, rule), name)
}
require.Error(t, experiments.ValidateRule(unscoped, experiments.Rule{Mode: experiments.ModeOn}))
}
func TestDiagnosticsNotLogged(t *testing.T) {
t.Parallel()
cases := map[string]struct {
src string
compileFail bool
// message is part of the CEL message text for this failure.
message string
category string
}{
"syntax": {src: `user.email == "` + sentinel + `" &&`, compileFail: true, message: "Syntax error", category: "syntax"},
"type": {src: `user.email == 1 || user.email == "` + sentinel + `"`, compileFail: true, message: "no matching overload", category: "type"},
"runtime": {src: `user.email == "x" || int("` + sentinel + `") == 1`, message: "conversion error", category: "eval"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
userID := uuid.New()
validateErr := experiments.ValidateRule(scoped, *condition(tc.src))
if tc.compileFail {
require.Error(t, validateErr)
} else {
require.NoError(t, validateErr)
}
e, sink := newEvaluator(t, storeWith(t, userID, condition(tc.src)), codersdk.Experiments{scoped})
require.False(t, e.Enabled(ctx, userID, scoped))
require.NotEmpty(t, sink.Entries(), "failure must be logged")
forbidden := []string{sentinel, tc.src, tc.message}
if validateErr != nil {
require.Contains(t, validateErr.Error(), tc.message)
forbidden = append(forbidden, validateErr.Error())
}
requireLogsExclude(t, sink, forbidden...)
requireLogField(t, sink, "category", tc.category)
requireLogField(t, sink, "experiment", scoped)
requireLogField(t, sink, "revision", int64(3))
})
}
}
func TestDecisions(t *testing.T) {
t.Parallel()
cases := map[string]struct {
rule *experiments.Rule
static bool
want bool
wantUserLoads int64
}{
"none static on": {static: true, want: true},
"none static off": {static: false, want: false},
"inherit static on": {rule: &experiments.Rule{Mode: experiments.ModeInherit}, static: true, want: true},
"inherit static off": {rule: &experiments.Rule{Mode: experiments.ModeInherit}, static: false, want: false},
"on": {rule: &experiments.Rule{Mode: experiments.ModeOn}, want: true},
"off static on": {rule: &experiments.Rule{Mode: experiments.ModeOff}, static: true, want: false},
"email": {rule: condition(`user.email == "Alice@Example.com"`), want: true, wantUserLoads: 1},
"email case": {rule: condition(`user.email == "alice@example.com"`), static: true, want: false, wantUserLoads: 1},
"username id": {rule: condition(`user.username == "alice" && user.id == "` + testUser.ID + `"`), want: true, wantUserLoads: 1},
"role": {rule: condition(`"owner" in user.roles`), want: true, wantUserLoads: 1},
"missing role": {rule: condition(`"member" in user.roles`), want: false, wantUserLoads: 1},
"org": {rule: condition(`"acme" in user.organizations`), want: true, wantUserLoads: 1},
"group": {rule: condition(`"coder/beta" in user.groups`), want: true, wantUserLoads: 1},
"everyone": {rule: condition(`"acme/Everyone" in user.groups`), want: true, wantUserLoads: 1},
"and": {rule: condition(`"coder" in user.organizations && "acme/beta" in user.groups`), want: false, wantUserLoads: 1},
"or": {rule: condition(`"other" in user.organizations || "coder/beta" in user.groups`), want: true, wantUserLoads: 1},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
userID := uuid.New()
store := storeWith(t, userID, tc.rule)
var static codersdk.Experiments
if tc.static {
static = codersdk.Experiments{scoped}
}
e, _ := newEvaluator(t, store, static)
require.Equal(t, tc.want, e.Enabled(ctx, userID, scoped))
require.EqualValues(t, 1, store.rules.Load())
require.Equal(t, tc.wantUserLoads, store.users.Load())
})
}
}
func TestUnscopedUsesStatic(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
// Rules for experiments outside ExperimentsUserScoped are inert, and
// the subject does not matter.
store := &countingStore{Store: experimentstest.Store{
RulesErr: xerrors.New("boom"),
}}
e, _ := newEvaluator(t, store, codersdk.Experiments{unscoped})
require.True(t, e.Enabled(ctx, uuid.Nil, unscoped))
require.EqualValues(t, 0, store.rules.Load())
store = &countingStore{Store: experimentstest.Store{StoredRules: map[codersdk.Experiment]experiments.StoredRule{
unscoped: experimentstest.StoredRule(t, experiments.Rule{Mode: experiments.ModeOff}),
}}}
e, _ = newEvaluator(t, store, codersdk.Experiments{unscoped})
require.True(t, e.Enabled(ctx, uuid.New(), unscoped))
}
func TestFailClosed(t *testing.T) {
t.Parallel()
// manyGroups makes nested comprehensions exceed the cost limit.
manyGroups := make([]string, 200)
for i := range manyGroups {
manyGroups[i] = fmt.Sprintf("coder/g%d", i)
}
cases := map[string]struct {
store func(t *testing.T, userID uuid.UUID) experiments.Store
ctx func(ctx context.Context) context.Context
nilSubject bool
wantCategory string
}{
"rules read error": {store: func(_ *testing.T, _ uuid.UUID) experiments.Store {
return experimentstest.Store{RulesErr: xerrors.New("db down")}
}},
"malformed json": {store: func(_ *testing.T, userID uuid.UUID) experiments.Store {
return rawStore(userID, `{"mode": "inherit"`)
}},
"unknown mode": {store: func(_ *testing.T, userID uuid.UUID) experiments.Store {
return rawStore(userID, `{"mode": "percent", "revision": 2}`)
}},
"condition without text": {store: func(_ *testing.T, userID uuid.UUID) experiments.Store {
return rawStore(userID, `{"mode": "condition", "revision": 2}`)
}},
"attribute load error": {store: func(t *testing.T, _ uuid.UUID) experiments.Store {
return experimentstest.Store{
StoredRules: map[codersdk.Experiment]experiments.StoredRule{
scoped: experimentstest.StoredRule(t, *condition(`true`)),
},
UserErr: xerrors.New("db down"),
}
}},
"compile error": {store: func(t *testing.T, userID uuid.UUID) experiments.Store {
return storeWith(t, userID, condition(`user.nickname == "x"`))
}},
"runtime error": {store: func(t *testing.T, userID uuid.UUID) experiments.Store {
return storeWith(t, userID, condition(`user.roles[5] == "owner"`))
}},
"cost overrun": {store: func(t *testing.T, userID uuid.UUID) experiments.Store {
return experimentstest.Store{
StoredRules: map[codersdk.Experiment]experiments.StoredRule{
scoped: experimentstest.StoredRule(t, *condition(`user.groups.all(a, user.groups.all(b, a != b || true))`)),
},
Users: map[uuid.UUID]experiments.User{userID: {Groups: manyGroups}},
}
}},
"canceled context": {
store: func(t *testing.T, userID uuid.UUID) experiments.Store {
return storeWith(t, userID, condition(`true`))
},
ctx: canceledContext,
wantCategory: "canceled",
},
// The store ignores cancellation, so the rules read succeeds and
// only the decision itself can notice the canceled context.
"canceled context with on rule": {
store: func(t *testing.T, userID uuid.UUID) experiments.Store {
return storeWith(t, userID, &experiments.Rule{Mode: experiments.ModeOn, Revision: 2})
},
ctx: canceledContext,
wantCategory: "canceled",
},
"canceled context without rule and static on": {
store: func(t *testing.T, userID uuid.UUID) experiments.Store {
return storeWith(t, userID, nil)
},
ctx: canceledContext,
wantCategory: "canceled",
},
"nil subject": {
store: func(t *testing.T, userID uuid.UUID) experiments.Store {
return storeWith(t, userID, &experiments.Rule{Mode: experiments.ModeInherit})
},
nilSubject: true,
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
if tc.ctx != nil {
ctx = tc.ctx(ctx)
}
userID := uuid.New()
store := tc.store(t, userID)
if tc.nilSubject {
userID = uuid.Nil
}
e, sink := newEvaluator(t, store, codersdk.Experiments{scoped, unscoped})
require.False(t, e.Enabled(ctx, userID, scoped))
require.Equal(t, codersdk.Experiments{unscoped}, e.EnabledExperiments(ctx, userID))
require.NotEmpty(t, sink.Entries(), "fail-closed decisions must be logged")
if tc.wantCategory != "" {
requireLogField(t, sink, "category", tc.wantCategory)
}
})
}
}
// TestRuleWithoutPositiveRevisionIsMalformed covers values written outside
// WriteRule: every stored rule has revision 1 or higher, so a value without
// one decides off even when its mode is valid.
func TestRuleWithoutPositiveRevisionIsMalformed(t *testing.T) {
t.Parallel()
src := fmt.Sprintf(`user.email != %q`, sentinel)
cases := map[string]string{
"on revision 0": `{"mode":"on","revision":0}`,
"on negative revision": `{"mode":"on","revision":-2}`,
"on missing revision": `{"mode":"on"}`,
"inherit missing": `{"mode":"inherit"}`,
"condition revision 0": fmt.Sprintf(`{"mode":"condition","condition":%q,"revision":0}`, src),
"condition missing": fmt.Sprintf(`{"mode":"condition","condition":%q}`, src),
"condition negative": fmt.Sprintf(`{"mode":"condition","condition":%q,"revision":-1}`, src),
"undecodable revision 0": `{"mode":"on","revision":0,"updated_by":"` + sentinel + `"}`,
}
for name, raw := range cases {
t.Run(name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
userID := uuid.New()
e, sink := newEvaluator(t, rawStore(userID, raw), codersdk.Experiments{scoped, unscoped})
require.False(t, e.Enabled(ctx, userID, scoped))
require.Equal(t, codersdk.Experiments{unscoped}, e.EnabledExperiments(ctx, userID))
requireLogField(t, sink, "category", "malformed")
requireLogField(t, sink, "experiment", scoped)
requireLogsExclude(t, sink, sentinel, raw)
})
}
}
// TestCanceledStoreReadIsNotAnError checks that a store read failing
// because the caller went away logs at Debug with category canceled, while
// other read failures stay errors. Every case decides off.
func TestCanceledStoreReadIsNotAnError(t *testing.T) {
t.Parallel()
canceled := xerrors.Errorf("read: %w", context.Canceled)
expired := xerrors.Errorf("read: %w", context.DeadlineExceeded)
cases := map[string]struct {
store func(t *testing.T) experiments.Store
wantCategory string
wantLevel slog.Level
}{
"rules canceled": {
store: func(*testing.T) experiments.Store { return experimentstest.Store{RulesErr: canceled} },
wantCategory: "canceled", wantLevel: slog.LevelDebug,
},
"rules deadline": {
store: func(*testing.T) experiments.Store { return experimentstest.Store{RulesErr: expired} },
wantCategory: "canceled", wantLevel: slog.LevelDebug,
},
"rules failure": {
store: func(*testing.T) experiments.Store { return experimentstest.Store{RulesErr: xerrors.New("db down")} },
wantCategory: "read", wantLevel: slog.LevelError,
},
"attributes canceled": {
store: func(t *testing.T) experiments.Store {
return experimentstest.Store{
StoredRules: map[codersdk.Experiment]experiments.StoredRule{scoped: experimentstest.StoredRule(t, *condition(`true`))},
UserErr: canceled,
}
},
wantCategory: "canceled", wantLevel: slog.LevelDebug,
},
"attributes failure": {
store: func(t *testing.T) experiments.Store {
return experimentstest.Store{
StoredRules: map[codersdk.Experiment]experiments.StoredRule{scoped: experimentstest.StoredRule(t, *condition(`true`))},
UserErr: xerrors.New("db down"),
}
},
wantCategory: "read", wantLevel: slog.LevelError,
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
e, sink := newEvaluator(t, tc.store(t), codersdk.Experiments{scoped})
require.False(t, e.Enabled(ctx, uuid.New(), scoped))
entries := sink.Entries()
require.Len(t, entries, 1)
require.Equal(t, tc.wantLevel, entries[0].Level)
requireLogField(t, sink, "category", tc.wantCategory)
})
}
}
func canceledContext(ctx context.Context) context.Context {
ctx, cancel := context.WithCancel(ctx)
cancel()
return ctx
}
// TestRuleChangesApplyToNextCall edits the stored rule between calls on one
// Evaluator: each call must reflect the current rule, never an earlier
// program or decision, including after a failed read.
func TestRuleChangesApplyToNextCall(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
userID := uuid.New()
store := &swapStore{Store: storeWith(t, userID, nil)}
e, _ := newEvaluator(t, store, nil)
steps := []struct {
name string
// rule nil means the rules read fails.
rule *experiments.Rule
want bool
}{
{"matching condition", condition(`"owner" in user.roles`), true},
{"edited condition", condition(`"member" in user.roles`), false},
{"original condition again", condition(`"owner" in user.roles`), true},
{"read failure", nil, false},
{"after read failure", condition(`"owner" in user.roles`), true},
{"off", &experiments.Rule{Mode: experiments.ModeOff}, false},
{"on", &experiments.Rule{Mode: experiments.ModeOn}, true},
}
for _, step := range steps {
if step.rule == nil {
store.rules.Store(nil)
} else {
rules := map[codersdk.Experiment]experiments.StoredRule{
scoped: experimentstest.StoredRule(t, *step.rule),
}
store.rules.Store(&rules)
}
require.Equal(t, step.want, e.Enabled(ctx, userID, scoped), step.name)
}
}
// swapStore returns the rules currently stored in rules, and fails the read
// while rules is nil.
type swapStore struct {
experiments.Store
rules atomic.Pointer[map[codersdk.Experiment]experiments.StoredRule]
}
func (s *swapStore) Rules(context.Context) (map[codersdk.Experiment]experiments.StoredRule, error) {
rules := s.rules.Load()
if rules == nil {
return nil, xerrors.New("db down")
}
return *rules, nil
}
func TestEnabledExperiments(t *testing.T) {
t.Parallel()
unknown := codersdk.Experiment("not-a-real-experiment")
t.Run("KeepsStaticOrderAndUnknown", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
userID := uuid.New()
static := codersdk.Experiments{unknown, scoped, unscoped}
store := storeWith(t, userID, nil)
e, _ := newEvaluator(t, store, static)
require.Equal(t, static, e.EnabledExperiments(ctx, userID))
require.EqualValues(t, 1, store.rules.Load())
require.EqualValues(t, 0, store.users.Load())
})
t.Run("RemovesDecidedOff", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
userID := uuid.New()
store := storeWith(t, userID, condition(`"nobody" in user.roles`))
e, _ := newEvaluator(t, store, codersdk.Experiments{unknown, scoped, unscoped})
require.Equal(t, codersdk.Experiments{unknown, unscoped}, e.EnabledExperiments(ctx, userID))
require.EqualValues(t, 1, store.rules.Load())
require.EqualValues(t, 1, store.users.Load())
})
t.Run("AppendsDecidedOn", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
userID := uuid.New()
store := storeWith(t, userID, condition(`"owner" in user.roles`))
e, _ := newEvaluator(t, store, codersdk.Experiments{unscoped, unknown})
require.Equal(t, codersdk.Experiments{unscoped, unknown, scoped}, e.EnabledExperiments(ctx, userID))
require.EqualValues(t, 1, store.rules.Load())
require.EqualValues(t, 1, store.users.Load())
})
}
func TestConcurrentEvaluation(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
userID := uuid.New()
sources := []string{`"owner" in user.roles`, `"member" in user.roles`, `user.username == "alice"`}
wants := []bool{true, false, true}
rules := make([]map[codersdk.Experiment]experiments.StoredRule, len(sources))
for i, src := range sources {
rules[i] = map[codersdk.Experiment]experiments.StoredRule{
scoped: experimentstest.StoredRule(t, *condition(src)),
}
}
// Each goroutine selects its rule through the context, so all of them
// share one evaluator and its program cache.
store := &ctxStore{Store: storeWith(t, userID, nil), rules: rules}
e, _ := newEvaluator(t, store, nil)
var wg sync.WaitGroup
for g := range 12 {
wg.Add(1)
go func() {
defer wg.Done()
i := g % len(sources)
gctx := context.WithValue(ctx, ruleIndexKey{}, i)
for range 50 {
assert.Equal(t, wants[i], e.Enabled(gctx, userID, scoped), sources[i])
}
}()
}
wg.Wait()
}
type ruleIndexKey struct{}
// ctxStore returns the rules selected by the ruleIndexKey context value.
type ctxStore struct {
experiments.Store
rules []map[codersdk.Experiment]experiments.StoredRule
}
func (s *ctxStore) Rules(ctx context.Context) (map[codersdk.Experiment]experiments.StoredRule, error) {
i, ok := ctx.Value(ruleIndexKey{}).(int)
if !ok {
return nil, xerrors.New("missing rule index")
}
return s.rules[i], nil
}