Ctrl K
ComunidadGuías…

coderd/experimentrules_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package coderd_test

import (
	"context"
	"fmt"
	"net/http"
	"testing"

	"github.com/stretchr/testify/require"

	"github.com/coder/coder/v2/coderd/coderdtest"
	"github.com/coder/coder/v2/coderd/database"
	"github.com/coder/coder/v2/coderd/database/dbauthz"
	"github.com/coder/coder/v2/coderd/database/dbtestutil"
	"github.com/coder/coder/v2/coderd/rbac"
	"github.com/coder/coder/v2/codersdk"
	"github.com/coder/coder/v2/testutil"
)

// ruleWriter writes experiment rules through the experimental API and
// tracks the revision of each experiment so that every write expects the
// current revision.
type ruleWriter struct {
	t         *testing.T
	client    *codersdk.ExperimentalClient
	revisions map[codersdk.Experiment]int64
}

func newRuleWriter(t *testing.T, client *codersdk.Client) *ruleWriter {
	return &ruleWriter{t: t, client: codersdk.NewExperimentalClient(client), revisions: map[codersdk.Experiment]int64{}}
}

func (w *ruleWriter) put(ctx context.Context, ex codersdk.Experiment, mode codersdk.ExperimentRuleMode, condition string) codersdk.ExperimentRule {
	w.t.Helper()
	rule, err := w.client.PutExperimentRule(ctx, ex, codersdk.PutExperimentRuleRequest{
		Mode:             mode,
		Condition:        condition,
		ExpectedRevision: w.revisions[ex],
	})
	require.NoError(w.t, err)
	require.Equal(w.t, w.revisions[ex]+1, rule.Revision)
	w.revisions[ex] = rule.Revision
	return rule
}

func requireEnabled(ctx context.Context, t *testing.T, client *codersdk.Client, ex codersdk.Experiment, want bool) {
	t.Helper()
	got, err := client.Experiments(ctx)
	require.NoError(t, err)
	require.Equal(t, want, got.Enabled(ex), "experiment %q for the caller", ex)
}

func requireStatus(t *testing.T, err error, status int) *codersdk.Error {
	t.Helper()
	var sdkErr *codersdk.Error
	require.ErrorAs(t, err, &sdkErr)
	require.Equal(t, status, sdkErr.StatusCode(), sdkErr.Error())
	return sdkErr
}

func ruleEntry(ctx context.Context, t *testing.T, client *codersdk.ExperimentalClient, ex codersdk.Experiment) codersdk.ExperimentRuleEntry {
	t.Helper()
	entries, err := client.ExperimentRules(ctx)
	require.NoError(t, err)
	for _, entry := range entries {
		if entry.Experiment == string(ex) {
			return entry
		}
	}
	t.Fatalf("no rules entry for experiment %q", ex)
	return codersdk.ExperimentRuleEntry{}
}

func TestExperimentRules(t *testing.T) {
	t.Parallel()

	t.Run("WritesApplyToNextRequest", func(t *testing.T) {
		t.Parallel()
		ownerClient := coderdtest.New(t, &coderdtest.Options{
			DeploymentValues: coderdtest.DeploymentValues(t, func(v *codersdk.DeploymentValues) {
				v.Experiments = []string{string(codersdk.ExperimentExample)}
			}),
		})
		owner := coderdtest.CreateFirstUser(t, ownerClient)
		memberClient, member := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
		ctx := testutil.Context(t, testutil.WaitLong)
		w := newRuleWriter(t, ownerClient)

		entries, err := w.client.ExperimentRules(ctx)
		require.NoError(t, err)
		require.Equal(t, []codersdk.ExperimentRuleEntry{
			{Experiment: string(codersdk.ExperimentExample), StaticDefault: true},
		}, entries)

		w.put(ctx, codersdk.ExperimentExample, codersdk.ExperimentRuleModeCondition, fmt.Sprintf("user.username == %q", member.Username))
		requireEnabled(ctx, t, memberClient, codersdk.ExperimentExample, true)
		requireEnabled(ctx, t, ownerClient, codersdk.ExperimentExample, false)

		w.put(ctx, codersdk.ExperimentExample, codersdk.ExperimentRuleModeOn, "")
		requireEnabled(ctx, t, ownerClient, codersdk.ExperimentExample, true)

		// off is a kill switch for a statically enabled experiment, and
		// reset restores the startup default.
		w.put(ctx, codersdk.ExperimentExample, codersdk.ExperimentRuleModeOff, "")
		requireEnabled(ctx, t, memberClient, codersdk.ExperimentExample, false)
		w.put(ctx, codersdk.ExperimentExample, codersdk.ExperimentRuleModeInherit, "")
		requireEnabled(ctx, t, memberClient, codersdk.ExperimentExample, true)

		entry := ruleEntry(ctx, t, w.client, codersdk.ExperimentExample)
		require.True(t, entry.StaticDefault)
		require.NotNil(t, entry.Rule)
		require.Equal(t, string(codersdk.ExperimentRuleModeInherit), entry.Rule.Mode)
		require.Equal(t, int64(4), entry.Rule.Revision)
		require.Equal(t, owner.UserID, entry.Rule.UpdatedBy)
		require.False(t, entry.Rule.UpdatedAt.IsZero())

		// Responses carry condition text and must never be cached.
		for _, req := range []struct {
			method, path string
			body         any
		}{
			{http.MethodGet, "/api/experimental/experiments/rules", nil},
			{http.MethodPut, "/api/experimental/experiments/rules/example", codersdk.PutExperimentRuleRequest{Mode: codersdk.ExperimentRuleModeInherit, ExpectedRevision: 4}},
		} {
			res, err := ownerClient.Request(ctx, req.method, req.path, req.body)
			require.NoError(t, err)
			_ = res.Body.Close()
			require.Equal(t, http.StatusOK, res.StatusCode, req.method)
			require.Equal(t, "no-store", res.Header.Get("Cache-Control"), req.method)
		}
	})

	t.Run("RejectedWritesKeepRule", func(t *testing.T) {
		t.Parallel()
		ownerClient := coderdtest.New(t, nil)
		_ = coderdtest.CreateFirstUser(t, ownerClient)
		ctx := testutil.Context(t, testutil.WaitLong)
		w := newRuleWriter(t, ownerClient)
		stored := w.put(ctx, codersdk.ExperimentExample, codersdk.ExperimentRuleModeOn, "")

		put := func(ex codersdk.Experiment, req codersdk.PutExperimentRuleRequest) error {
			_, err := w.client.PutExperimentRule(ctx, ex, req)
			return err
		}

		// The author of an invalid condition receives the diagnostics.
		err := put(codersdk.ExperimentExample, codersdk.PutExperimentRuleRequest{Mode: codersdk.ExperimentRuleModeCondition, Condition: `user.username == "a" &&`, ExpectedRevision: 1})
		require.Contains(t, requireStatus(t, err, http.StatusBadRequest).Detail, "Syntax error")

		for _, req := range []codersdk.PutExperimentRuleRequest{
			{Mode: codersdk.ExperimentRuleModeOff, Condition: "true", ExpectedRevision: 1},
			{Mode: codersdk.ExperimentRuleModeCondition, ExpectedRevision: 1},
			{Mode: "percent", ExpectedRevision: 1},
			{Mode: codersdk.ExperimentRuleModeOff, ExpectedRevision: -1},
		} {
			requireStatus(t, put(codersdk.ExperimentExample, req), http.StatusBadRequest)
		}
		for _, ex := range []codersdk.Experiment{codersdk.ExperimentWorkspaceUsage, "not-an-experiment"} {
			requireStatus(t, put(ex, codersdk.PutExperimentRuleRequest{Mode: codersdk.ExperimentRuleModeOn}), http.StatusBadRequest)
		}

		// A stale revision conflicts; the same rule at the current
		// revision is an accepted no-op.
		requireStatus(t, put(codersdk.ExperimentExample, codersdk.PutExperimentRuleRequest{Mode: codersdk.ExperimentRuleModeOff}), http.StatusConflict)
		same, err := w.client.PutExperimentRule(ctx, codersdk.ExperimentExample, codersdk.PutExperimentRuleRequest{Mode: codersdk.ExperimentRuleModeOn, ExpectedRevision: 1})
		require.NoError(t, err)
		require.Equal(t, stored, same)

		entries, err := w.client.ExperimentRules(ctx)
		require.NoError(t, err)
		require.Equal(t, []codersdk.ExperimentRuleEntry{
			{Experiment: string(codersdk.ExperimentExample), Rule: &stored},
		}, entries)
	})

	// Authorization runs before the body is parsed or compiled: a caller
	// without update access gets 403 even for an invalid condition.
	t.Run("Authorization", func(t *testing.T) {
		t.Parallel()
		ownerClient := coderdtest.New(t, nil)
		owner := coderdtest.CreateFirstUser(t, ownerClient)
		memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
		auditorClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleAuditor())
		ctx := testutil.Context(t, testutil.WaitLong)

		member := codersdk.NewExperimentalClient(memberClient)
		_, err := member.ExperimentRules(ctx)
		requireStatus(t, err, http.StatusForbidden)

		auditor := codersdk.NewExperimentalClient(auditorClient)
		_, err = auditor.ExperimentRules(ctx)
		require.NoError(t, err)

		for _, client := range []*codersdk.ExperimentalClient{member, auditor} {
			for _, req := range []codersdk.PutExperimentRuleRequest{
				{Mode: codersdk.ExperimentRuleModeOn},
				{Mode: codersdk.ExperimentRuleModeCondition, Condition: "user."},
			} {
				_, err := client.PutExperimentRule(ctx, codersdk.ExperimentExample, req)
				requireStatus(t, err, http.StatusForbidden)
			}
		}
		require.Nil(t, ruleEntry(ctx, t, codersdk.NewExperimentalClient(ownerClient), codersdk.ExperimentExample).Rule)
	})

	// Stored rules the API cannot write are listed but ignored, and a
	// malformed rule of a user-scoped experiment stays replaceable, even
	// when its revision is unreadable.
	t.Run("StoredRulesOutsideTheAPI", func(t *testing.T) {
		t.Parallel()
		ownerClient, db := coderdtest.NewWithDatabase(t, nil)
		owner := coderdtest.CreateFirstUser(t, ownerClient)
		memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
		ctx := testutil.Context(t, testutil.WaitLong)
		sysCtx := dbauthz.AsSystemRestricted(ctx)
		store := func(ex codersdk.Experiment, value string) {
			t.Helper()
			require.NoError(t, db.UpsertExperimentRule(sysCtx, database.UpsertExperimentRuleParams{Experiment: string(ex), Value: value}))
		}
		for ex, value := range map[codersdk.Experiment]string{
			"not-an-experiment":               `{"mode":"on","revision":1}`,
			codersdk.ExperimentWorkspaceUsage: `{"mode":"off","revision":4}`,
			codersdk.ExperimentExample:        `{"mode":"percent","revision":2}`,
		} {
			store(ex, value)
		}
		client := codersdk.NewExperimentalClient(ownerClient)

		entries, err := client.ExperimentRules(ctx)
		require.NoError(t, err)
		require.Equal(t, []codersdk.ExperimentRuleEntry{
			{Experiment: string(codersdk.ExperimentExample), Rule: &codersdk.ExperimentRule{Revision: 2}},
			{Experiment: "not-an-experiment", Rule: &codersdk.ExperimentRule{Mode: string(codersdk.ExperimentRuleModeOn), Revision: 1}, Ignored: true},
			{Experiment: string(codersdk.ExperimentWorkspaceUsage), Rule: &codersdk.ExperimentRule{Mode: string(codersdk.ExperimentRuleModeOff), Revision: 4}, Ignored: true},
		}, entries)

		rule, err := client.PutExperimentRule(ctx, codersdk.ExperimentExample, codersdk.PutExperimentRuleRequest{Mode: codersdk.ExperimentRuleModeOn, ExpectedRevision: 2})
		require.NoError(t, err)
		require.Equal(t, int64(3), rule.Revision)

		store(codersdk.ExperimentExample, `{not json`)
		require.Equal(t, &codersdk.ExperimentRule{}, ruleEntry(ctx, t, client, codersdk.ExperimentExample).Rule)
		_, err = client.PutExperimentRule(ctx, codersdk.ExperimentExample, codersdk.PutExperimentRuleRequest{Mode: codersdk.ExperimentRuleModeOn, ExpectedRevision: 5})
		requireStatus(t, err, http.StatusConflict)
		rule, err = client.PutExperimentRule(ctx, codersdk.ExperimentExample, codersdk.PutExperimentRuleRequest{Mode: codersdk.ExperimentRuleModeOn, ExpectedRevision: 0})
		require.NoError(t, err)
		require.Equal(t, int64(1), rule.Revision)
		for _, c := range []*codersdk.Client{ownerClient, memberClient} {
			requireEnabled(ctx, t, c, codersdk.ExperimentExample, true)
		}

		// An on rule without a positive revision is malformed: it is
		// listed at revision 0 and decides off for everyone.
		for _, value := range []string{`{"mode":"on","revision":0}`, `{"mode":"on"}`} {
			store(codersdk.ExperimentExample, value)
			require.Equal(t, &codersdk.ExperimentRule{}, ruleEntry(ctx, t, client, codersdk.ExperimentExample).Rule, value)
			for _, c := range []*codersdk.Client{ownerClient, memberClient} {
				requireEnabled(ctx, t, c, codersdk.ExperimentExample, false)
			}
		}
	})
}

// TestExperimentRulesReplicas checks that a rule written through one
// replica decides the next evaluation on another replica sharing the
// database, both for the API and for server checks through the evaluator.
func TestExperimentRulesReplicas(t *testing.T) {
	t.Parallel()
	db, ps := dbtestutil.NewDB(t)
	options := func() *coderdtest.Options {
		return &coderdtest.Options{Database: db, Pubsub: ps}
	}
	clientA := coderdtest.New(t, options())
	clientB, _, apiB := coderdtest.NewWithAPI(t, options())
	owner := coderdtest.CreateFirstUser(t, clientA)
	memberA, member := coderdtest.CreateAnotherUser(t, clientA, owner.OrganizationID)
	memberB := codersdk.New(clientB.URL)
	memberB.SetSessionToken(memberA.SessionToken())
	ctx := testutil.Context(t, testutil.WaitLong)
	w := newRuleWriter(t, clientA)

	requireB := func(want bool) {
		t.Helper()
		requireEnabled(ctx, t, memberB, codersdk.ExperimentExample, want)
		require.Equal(t, want, apiB.ExperimentEvaluator.Enabled(ctx, member.ID, codersdk.ExperimentExample))
	}
	requireB(false)
	w.put(ctx, codersdk.ExperimentExample, codersdk.ExperimentRuleModeCondition, fmt.Sprintf("user.id == %q", member.ID))
	requireB(true)
	w.put(ctx, codersdk.ExperimentExample, codersdk.ExperimentRuleModeOff, "")
	requireB(false)
}