Ctrl K
ComunidadGuías…

coderd/templates_meta_update_internal_test.go

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

Archivo original ↗Preguntar a KiBot
package coderd

import (
	"reflect"
	"testing"

	"github.com/google/uuid"

	"github.com/coder/coder/v2/coderd/database"
	"github.com/coder/coder/v2/coderd/schedule"
	"github.com/coder/coder/v2/codersdk"
	"github.com/coder/serpent"
)

// baselineTemplate returns a database.Template populated with non-default
// values for every field that resolveTemplateMetaUpdate reads. Non-default
// values let single-field tests detect when a field is being silently
// overwritten with a zero value.
func baselineTemplate() database.Template {
	orgID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
	return database.Template{
		ID:                            uuid.MustParse("00000000-0000-0000-0000-000000000002"),
		OrganizationID:                orgID,
		Name:                          "baseline",
		DisplayName:                   "Baseline Template",
		Description:                   "An existing description.",
		Icon:                          "/baseline.svg",
		AllowUserAutostart:            false,
		AllowUserAutostop:             false,
		AllowUserCancelWorkspaceJobs:  false,
		AgentsAllowed:                 true,
		RequireActiveVersion:          true,
		DefaultTTL:                    int64(60 * 60 * 1000 * 1000 * 1000),  // 1 hour in ns
		ActivityBump:                  int64(30 * 60 * 1000 * 1000 * 1000),  // 30 minutes in ns
		TimeTilAutostopNotify:         int64(10 * 60 * 1000 * 1000 * 1000),  // 10 minutes in ns
		FailureTTL:                    int64(120 * 60 * 1000 * 1000 * 1000), // 2 hours in ns
		TimeTilDormant:                int64(240 * 60 * 1000 * 1000 * 1000), // 4 hours in ns
		TimeTilDormantAutoDelete:      int64(480 * 60 * 1000 * 1000 * 1000), // 8 hours in ns
		AutostopRequirementDaysOfWeek: 0b0000001,                            // Monday
		AutostopRequirementWeeks:      2,
		AutostartBlockDaysOfWeek:      0b1000000,    // Sunday
		Deprecated:                    "deprecated", // non-empty so the conversion is observable
		MaxPortSharingLevel:           database.AppSharingLevelOrganization,
		UseClassicParameterFlow:       true,
		CorsBehavior:                  database.CorsBehaviorPassthru,
		DisableModuleCache:            true,
		AllowWorkspaceRenames:         true,
		GroupACL: database.TemplateACL{
			orgID.String(): {"read"},
		},
	}
}

// baselineScheduleOpts returns schedule options matching the baseline
// template above, so that nil request fields resolve to these values.
func baselineScheduleOpts() schedule.TemplateScheduleOptions {
	return schedule.TemplateScheduleOptions{
		AutostopRequirement: schedule.TemplateAutostopRequirement{
			DaysOfWeek: 0b0000001,
			Weeks:      2,
		},
		AutostartRequirement: schedule.TemplateAutostartRequirement{
			DaysOfWeek: 0b1000000,
		},
	}
}

// baselineResolved returns the templateMetaUpdate that resolveTemplateMetaUpdate
// produces for an empty request against baselineTemplate / baselineScheduleOpts.
func baselineResolved() templateMetaUpdate {
	tpl := baselineTemplate()
	return templateMetaUpdate{
		name:                                 tpl.Name,
		displayName:                          tpl.DisplayName,
		description:                          tpl.Description,
		icon:                                 tpl.Icon,
		defaultTTLMillis:                     tpl.DefaultTTL / 1e6,
		activityBumpMillis:                   tpl.ActivityBump / 1e6,
		timeTilAutostopNotifyMillis:          tpl.TimeTilAutostopNotify / 1e6,
		failureTTLMillis:                     tpl.FailureTTL / 1e6,
		timeTilDormantMillis:                 tpl.TimeTilDormant / 1e6,
		timeTilDormantAutoDeleteMillis:       tpl.TimeTilDormantAutoDelete / 1e6,
		allowUserAutostart:                   tpl.AllowUserAutostart,
		allowUserAutostop:                    tpl.AllowUserAutostop,
		allowUserCancelWorkspaceJobs:         tpl.AllowUserCancelWorkspaceJobs,
		agentsAllowed:                        tpl.AgentsAllowed,
		requireActiveVersion:                 tpl.RequireActiveVersion,
		deprecationMessage:                   tpl.Deprecated,
		useClassicTemplateFlow:               tpl.UseClassicParameterFlow,
		disableModuleCache:                   tpl.DisableModuleCache,
		allowWorkspaceRenames:                tpl.AllowWorkspaceRenames,
		corsBehavior:                         tpl.CorsBehavior,
		autostopRequirementDaysOfWeekParsed:  0b0000001,
		autostartRequirementDaysOfWeekParsed: 0b1000000,
		autostopRequirementWeeks:             tpl.AutostopRequirementWeeks,
		groupACL:                             tpl.GroupACL,
	}
}

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

	type expected struct {
		// override is applied to baselineResolved to produce the expected
		// templateMetaUpdate. Allows each case to express only its delta.
		override func(*templateMetaUpdate)
		base     func(template *database.Template)
		// validErrFields, if non-empty, asserts the resolver produced a
		// validation error for each named field.
		validErrFields []string
	}

	tests := []struct {
		name     string
		req      codersdk.UpdateTemplateMeta
		expected expected
	}{
		// Sanity check: an empty PATCH preserves every field.
		{
			name:     "EmptyRequestPreservesEverything",
			req:      codersdk.UpdateTemplateMeta{},
			expected: expected{override: func(*templateMetaUpdate) {}},
		},

		// One case per pointer field: each case sends only that field
		// and asserts only that field changed in the resolved struct.
		{
			name: "Name",
			req:  codersdk.UpdateTemplateMeta{Name: new("renamed")},
			expected: expected{override: func(r *templateMetaUpdate) {
				r.name = "renamed"
			}},
		},
		{
			name: "NameEmptyStringFallsBackToCurrent",
			req:  codersdk.UpdateTemplateMeta{Name: new("")},
			// Empty string is treated as "do not clear" because the UI
			// disallows clearing the name. Resolver must keep the
			// existing name.
			// This is a unique case to just the `name` field.
			expected: expected{override: func(*templateMetaUpdate) {}},
		},
		{
			name: "DisplayName",
			req:  codersdk.UpdateTemplateMeta{DisplayName: new("Renamed")},
			expected: expected{override: func(r *templateMetaUpdate) {
				r.displayName = "Renamed"
			}},
		},
		{
			name: "Description",
			req:  codersdk.UpdateTemplateMeta{Description: new("New description")},
			expected: expected{override: func(r *templateMetaUpdate) {
				r.description = "New description"
			}},
		},
		{
			name: "Icon",
			req:  codersdk.UpdateTemplateMeta{Icon: new("/new.svg")},
			expected: expected{override: func(r *templateMetaUpdate) {
				r.icon = "/new.svg"
			}},
		},
		{
			name: "DefaultTTLMillis",
			req:  codersdk.UpdateTemplateMeta{DefaultTTLMillis: new(int64(7200_000))},
			expected: expected{override: func(r *templateMetaUpdate) {
				r.defaultTTLMillis = 7200_000
			}},
		},
		{
			name: "DefaultTTLMillisZeroExplicit",
			req:  codersdk.UpdateTemplateMeta{DefaultTTLMillis: new(int64(0))},
			expected: expected{override: func(r *templateMetaUpdate) {
				r.defaultTTLMillis = 0
			}},
		},
		{
			name: "ActivityBumpMillis",
			req:  codersdk.UpdateTemplateMeta{ActivityBumpMillis: new(int64(900_000))},
			expected: expected{override: func(r *templateMetaUpdate) {
				r.activityBumpMillis = 900_000
			}},
		},
		{
			name: "TimeTilAutostopNotifyMillis",
			req:  codersdk.UpdateTemplateMeta{TimeTilAutostopNotifyMillis: new(int64(300_000))},
			expected: expected{override: func(r *templateMetaUpdate) {
				r.timeTilAutostopNotifyMillis = 300_000
			}},
		},
		{
			name: "TimeTilAutostopNotifyMillisZeroExplicit",
			req:  codersdk.UpdateTemplateMeta{TimeTilAutostopNotifyMillis: new(int64(0))},
			expected: expected{override: func(r *templateMetaUpdate) {
				r.timeTilAutostopNotifyMillis = 0
			}},
		},
		{
			name: "AllowUserAutostart",
			req:  codersdk.UpdateTemplateMeta{AllowUserAutostart: new(true)},
			expected: expected{override: func(r *templateMetaUpdate) {
				r.allowUserAutostart = true
			}},
		},
		{
			name: "AllowUserAutostop",
			req:  codersdk.UpdateTemplateMeta{AllowUserAutostop: new(true)},
			expected: expected{override: func(r *templateMetaUpdate) {
				r.allowUserAutostop = true
			}},
		},
		{
			name: "AllowUserAutostop/true",
			req:  codersdk.UpdateTemplateMeta{AllowUserAutostop: new(false)},
			expected: expected{
				base: func(update *database.Template) {
					update.AllowUserAutostop = true
				},
				override: func(r *templateMetaUpdate) {
					r.allowUserAutostop = false
				},
			},
		},
		{
			name: "AllowUserCancelWorkspaceJobs",
			req:  codersdk.UpdateTemplateMeta{AllowUserCancelWorkspaceJobs: new(true)},
			expected: expected{override: func(r *templateMetaUpdate) {
				r.allowUserCancelWorkspaceJobs = true
			}},
		},
		{
			name: "AgentsAllowed",
			req:  codersdk.UpdateTemplateMeta{AgentsAllowed: new(false)},
			expected: expected{override: func(r *templateMetaUpdate) {
				r.agentsAllowed = false
			}},
		},
		{
			name: "FailureTTLMillis",
			req:  codersdk.UpdateTemplateMeta{FailureTTLMillis: new(int64(3_600_000))},
			expected: expected{override: func(r *templateMetaUpdate) {
				r.failureTTLMillis = 3_600_000
			}},
		},
		{
			name: "TimeTilDormantMillis",
			req:  codersdk.UpdateTemplateMeta{TimeTilDormantMillis: new(int64(7_200_000))},
			expected: expected{override: func(r *templateMetaUpdate) {
				r.timeTilDormantMillis = 7_200_000
			}},
		},
		{
			name: "TimeTilDormantAutoDeleteMillis",
			req:  codersdk.UpdateTemplateMeta{TimeTilDormantAutoDeleteMillis: new(int64(14_400_000))},
			expected: expected{override: func(r *templateMetaUpdate) {
				r.timeTilDormantAutoDeleteMillis = 14_400_000
			}},
		},
		{
			name: "RequireActiveVersion",
			req:  codersdk.UpdateTemplateMeta{RequireActiveVersion: new(false)},
			expected: expected{override: func(r *templateMetaUpdate) {
				r.requireActiveVersion = false
			}},
		},
		{
			name: "DeprecationMessage",
			req:  codersdk.UpdateTemplateMeta{DeprecationMessage: new("now deprecated")},
			expected: expected{override: func(r *templateMetaUpdate) {
				r.deprecationMessage = "now deprecated"
			}},
		},
		{
			name: "DeprecationMessageEmptyStringClears",
			req:  codersdk.UpdateTemplateMeta{DeprecationMessage: new("")},
			expected: expected{override: func(r *templateMetaUpdate) {
				r.deprecationMessage = ""
			}},
		},
		{
			name: "UseClassicParameterFlow",
			req:  codersdk.UpdateTemplateMeta{UseClassicParameterFlow: new(false)},
			expected: expected{override: func(r *templateMetaUpdate) {
				r.useClassicTemplateFlow = false
			}},
		},
		{
			name: "DisableModuleCache",
			req:  codersdk.UpdateTemplateMeta{DisableModuleCache: new(false)},
			expected: expected{override: func(r *templateMetaUpdate) {
				r.disableModuleCache = false
			}},
		},
		{
			name: "AllowWorkspaceRenames",
			req:  codersdk.UpdateTemplateMeta{AllowWorkspaceRenames: new(false)},
			expected: expected{override: func(r *templateMetaUpdate) {
				r.allowWorkspaceRenames = false
			}},
		},

		// CORS behavior.
		{
			name: "CORSBehaviorChange",
			req: codersdk.UpdateTemplateMeta{
				CORSBehavior: new(codersdk.CORSBehavior(database.CorsBehaviorSimple)),
			},
			expected: expected{override: func(r *templateMetaUpdate) {
				r.corsBehavior = database.CorsBehaviorSimple
			}},
		},
		{
			name: "CORSBehaviorEmptyStringPreserves",
			req: codersdk.UpdateTemplateMeta{
				CORSBehavior: new(codersdk.CORSBehavior("")),
			},
			// Empty string is treated as "do not change" for backwards
			// compatibility with older clients that always send the
			// field.
			expected: expected{override: func(*templateMetaUpdate) {}},
		},
		{
			name: "CORSBehaviorInvalid",
			req: codersdk.UpdateTemplateMeta{
				CORSBehavior: new(codersdk.CORSBehavior("not-a-real-value")),
			},
			expected: expected{
				// Invalid value: keep current and surface a validation error.
				override:       func(*templateMetaUpdate) {},
				validErrFields: []string{"cors_behavior"},
			},
		},

		// Autostop / autostart requirement bitmaps.
		{
			name: "AutostopRequirementChange",
			req: codersdk.UpdateTemplateMeta{
				AutostopRequirement: &codersdk.TemplateAutostopRequirement{
					DaysOfWeek: []string{"friday"},
					Weeks:      4,
				},
			},
			expected: expected{override: func(r *templateMetaUpdate) {
				r.autostopRequirementDaysOfWeekParsed = 0b0010000
				r.autostopRequirementWeeks = 4
			}},
		},
		{
			name: "AutostopRequirementWeeksZeroNormalizesToOne",
			req: codersdk.UpdateTemplateMeta{
				AutostopRequirement: &codersdk.TemplateAutostopRequirement{
					DaysOfWeek: []string{"monday"},
					Weeks:      0,
				},
			},
			expected: expected{override: func(r *templateMetaUpdate) {
				r.autostopRequirementDaysOfWeekParsed = 0b0000001
				r.autostopRequirementWeeks = 1
			}},
		},
		{
			name: "AutostopRequirementInvalidDay",
			req: codersdk.UpdateTemplateMeta{
				AutostopRequirement: &codersdk.TemplateAutostopRequirement{
					DaysOfWeek: []string{"funday"},
					Weeks:      1,
				},
			},
			expected: expected{
				override: func(r *templateMetaUpdate) {
					r.autostopRequirementDaysOfWeekParsed = 1
					r.autostopRequirementWeeks = 2
				},
				validErrFields: []string{"autostop_requirement.days_of_week"},
			},
		},
		{
			name: "AutostartRequirementChange",
			req: codersdk.UpdateTemplateMeta{
				AutostartRequirement: &codersdk.TemplateAutostartRequirement{
					DaysOfWeek: []string{"saturday"},
				},
			},
			expected: expected{override: func(r *templateMetaUpdate) {
				r.autostartRequirementDaysOfWeekParsed = 0b0100000
			}},
		},
		{
			name: "AutostartRequirementInvalidDay",
			req: codersdk.UpdateTemplateMeta{
				AutostartRequirement: &codersdk.TemplateAutostartRequirement{
					DaysOfWeek: []string{"funday"},
				},
			},
			expected: expected{
				override: func(r *templateMetaUpdate) {
					r.autostartRequirementDaysOfWeekParsed = 64
				},
				validErrFields: []string{"autostart_requirement.days_of_week"},
			},
		},

		// One-shot intent flags. nil and false should both result in
		// the corresponding *Intent field being false; only true triggers it.
		{
			name: "DisableEveryoneGroupAccessFalseIsNoop",
			req:  codersdk.UpdateTemplateMeta{DisableEveryoneGroupAccess: new(false)},
			expected: expected{override: func(*templateMetaUpdate) {
				// disableEveryoneIntent stays false.
			}},
		},
		{
			name: "DisableEveryoneGroupAccessTrueWithMembership",
			req:  codersdk.UpdateTemplateMeta{DisableEveryoneGroupAccess: new(true)},
			expected: expected{override: func(r *templateMetaUpdate) {
				r.groupACL = database.TemplateACL{}
			}},
		},
		{
			name:     "UpdateWorkspaceLastUsedAtFalseIsNoop",
			req:      codersdk.UpdateTemplateMeta{UpdateWorkspaceLastUsedAt: new(false)},
			expected: expected{override: func(*templateMetaUpdate) {}},
		},
		{
			name: "UpdateWorkspaceLastUsedAtTrue",
			req:  codersdk.UpdateTemplateMeta{UpdateWorkspaceLastUsedAt: new(true)},
			expected: expected{override: func(r *templateMetaUpdate) {
				r.updateWorkspaceLastUsedAtIntent = true
			}},
		},
		{
			name:     "UpdateWorkspaceDormantAtFalseIsNoop",
			req:      codersdk.UpdateTemplateMeta{UpdateWorkspaceDormantAt: new(false)},
			expected: expected{override: func(*templateMetaUpdate) {}},
		},
		{
			name: "UpdateWorkspaceDormantAtTrue",
			req:  codersdk.UpdateTemplateMeta{UpdateWorkspaceDormantAt: new(true)},
			expected: expected{override: func(r *templateMetaUpdate) {
				r.updateWorkspaceDormantAtIntent = true
			}},
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()

			tpl := baselineTemplate()
			if tc.expected.base != nil {
				tc.expected.base(&tpl)
			}
			schedOpts := baselineScheduleOpts()
			got, validErrs := resolveTemplateMetaUpdate(tpl, schedOpts, tc.req, nil)

			want := baselineResolved()
			tc.expected.override(&want)

			if !reflect.DeepEqual(got, want) {
				t.Fatalf("resolved mismatch\ngot:  %+v\nwant: %+v", got, want)
			}

			if len(validErrs) != len(tc.expected.validErrFields) {
				t.Fatalf("got %d validation errors, want %d: %+v",
					len(validErrs), len(tc.expected.validErrFields), validErrs)
			}
			for i, field := range tc.expected.validErrFields {
				if validErrs[i].Field != field {
					t.Errorf("validation error %d: field = %q, want %q",
						i, validErrs[i].Field, field)
				}
			}
		})
	}
}

// TestResolveTemplateMetaUpdate_NameClearedFallsBackToTemplateName covers the
// pre-existing rule that a template's name cannot be cleared from the UI.
// Even an explicit empty pointer must resolve to the existing template name.
func TestResolveTemplateMetaUpdate_NameClearedFallsBackToTemplateName(t *testing.T) {
	t.Parallel()

	tpl := baselineTemplate()
	schedOpts := baselineScheduleOpts()

	got, _ := resolveTemplateMetaUpdate(tpl, schedOpts, codersdk.UpdateTemplateMeta{
		Name: new(""),
	}, nil)
	if got.name != tpl.Name {
		t.Fatalf("got name = %q, want %q (preserved)", got.name, tpl.Name)
	}
}

// TestResolveTemplateMetaUpdate_NilRequestUsesScheduleOptsForRequirements
// verifies that an entirely empty request returns the schedule store's
// current autostop/autostart requirement values, rather than zeros.
func TestResolveTemplateMetaUpdate_NilRequestUsesScheduleOptsForRequirements(t *testing.T) {
	t.Parallel()

	tpl := baselineTemplate()
	schedOpts := schedule.TemplateScheduleOptions{
		AutostopRequirement: schedule.TemplateAutostopRequirement{
			DaysOfWeek: 0b0001100, // Wed + Thu
			Weeks:      3,
		},
		AutostartRequirement: schedule.TemplateAutostartRequirement{
			DaysOfWeek: 0b0010000, // Fri
		},
	}

	got, validErrs := resolveTemplateMetaUpdate(tpl, schedOpts, codersdk.UpdateTemplateMeta{}, nil)
	if len(validErrs) != 0 {
		t.Fatalf("unexpected validation errors: %+v", validErrs)
	}
	if got.autostopRequirementDaysOfWeekParsed != schedOpts.AutostopRequirement.DaysOfWeek {
		t.Errorf("autostop days = 0b%07b, want 0b%07b",
			got.autostopRequirementDaysOfWeekParsed,
			schedOpts.AutostopRequirement.DaysOfWeek)
	}
	if got.autostartRequirementDaysOfWeekParsed != schedOpts.AutostartRequirement.DaysOfWeek {
		t.Errorf("autostart days = 0b%07b, want 0b%07b",
			got.autostartRequirementDaysOfWeekParsed,
			schedOpts.AutostartRequirement.DaysOfWeek)
	}
	if got.autostopRequirementWeeks != schedOpts.AutostopRequirement.Weeks {
		t.Errorf("autostop weeks = %d, want %d",
			got.autostopRequirementWeeks, schedOpts.AutostopRequirement.Weeks)
	}
}

// TestResolveTemplateMetaUpdate_ModuleCacheDisabledByDeployment verifies that the
// per-template toggle is read-only while the deployment disables the module
// cache: the request value is discarded in both directions.
func TestResolveTemplateMetaUpdate_ModuleCacheDisabledByDeployment(t *testing.T) {
	t.Parallel()

	cases := []struct {
		name     string
		stored   bool
		request  *bool
		expected bool
	}{
		{name: "IgnoresOptIn", stored: true, request: new(false), expected: true},
		{name: "IgnoresOptOut", stored: false, request: new(true), expected: false},
		{name: "PreservesStored", stored: true, request: nil, expected: true},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()

			tpl := baselineTemplate()
			tpl.DisableModuleCache = tc.stored
			dv := &codersdk.DeploymentValues{}
			dv.Provisioner.DisableModuleCache = serpent.Bool(true)
			got, validErrs := resolveTemplateMetaUpdate(tpl, baselineScheduleOpts(), codersdk.UpdateTemplateMeta{
				DisableModuleCache: tc.request,
			}, dv)
			if len(validErrs) != 0 {
				t.Fatalf("unexpected validation errors: %+v", validErrs)
			}
			if got.disableModuleCache != tc.expected {
				t.Errorf("disableModuleCache = %t, want %t", got.disableModuleCache, tc.expected)
			}
		})
	}
}