snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,870 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"maps"
"sync"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/app/properties"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/stretchr/testify/require"
)
func TestDoSetupManagedCategoryProperties(t *testing.T) {
t.Run("should register the property group and field on fresh install", func(t *testing.T) {
th := Setup(t)
group, appErr := th.App.GetPropertyGroup(th.Context, model.ManagedCategoryPropertyGroupName)
require.Nil(t, appErr)
require.NotNil(t, group)
require.Equal(t, model.ManagedCategoryPropertyGroupName, group.Name)
propertyFields, appErr := th.App.SearchPropertyFields(th.Context, group.ID, model.PropertyFieldSearchOpts{PerPage: 100})
require.Nil(t, appErr)
require.Len(t, propertyFields, 1)
require.Equal(t, model.ManagedCategoryPropertyFieldName, propertyFields[0].Name)
data, sysErr := th.Store.System().GetByName(managedCategorySetupDoneKey)
require.NoError(t, sysErr)
require.NotEmpty(t, data.Value)
})
t.Run("should upgrade from a pre-v2 setup by incrementing the group version and updating the system key", func(t *testing.T) {
th := Setup(t)
group, appErr := th.App.GetPropertyGroup(th.Context, model.ManagedCategoryPropertyGroupName)
require.Nil(t, appErr)
// Simulate the pre-v2 state where the migration has run with the legacy "true" marker.
sysErr := th.Store.System().SaveOrUpdate(&model.System{Name: managedCategorySetupDoneKey, Value: "true"})
require.NoError(t, sysErr)
initialVersion := group.Version
err := th.Server.doSetupManagedCategoryProperties()
require.NoError(t, err)
group, appErr = th.App.GetPropertyGroup(th.Context, model.ManagedCategoryPropertyGroupName)
require.Nil(t, appErr)
require.Equal(t, initialVersion+1, group.Version)
data, sysErr := th.Store.System().GetByName(managedCategorySetupDoneKey)
require.NoError(t, sysErr)
require.Equal(t, managedCategoryMigrationVersion, data.Value)
})
t.Run("should be idempotent when the system key is already at v2", func(t *testing.T) {
th := Setup(t)
sysErr := th.Store.System().SaveOrUpdate(&model.System{Name: managedCategorySetupDoneKey, Value: managedCategoryMigrationVersion})
require.NoError(t, sysErr)
group, appErr := th.App.GetPropertyGroup(th.Context, model.ManagedCategoryPropertyGroupName)
require.Nil(t, appErr)
versionBefore := group.Version
err := th.Server.doSetupManagedCategoryProperties()
require.NoError(t, err)
group, appErr = th.App.GetPropertyGroup(th.Context, model.ManagedCategoryPropertyGroupName)
require.Nil(t, appErr)
require.Equal(t, versionBefore, group.Version)
data, sysErr := th.Store.System().GetByName(managedCategorySetupDoneKey)
require.NoError(t, sysErr)
require.Equal(t, managedCategoryMigrationVersion, data.Value)
})
}
func TestDoSetupContentFlaggingProperties(t *testing.T) {
t.Run("should register property group and fields", func(t *testing.T) {
//we need to call the Setup method and run the full setup instead of
//just creating a new server via NewServer() because the Setup method
//also takes care of using the correct database DSN based on environment,
//settings, setting up the store and initializing services used in store such as property services.
th := Setup(t)
group, appErr := th.App.GetPropertyGroup(th.Context, model.ContentFlaggingGroupName)
require.Nil(t, appErr)
require.NotNil(t, group)
require.Equal(t, model.ContentFlaggingGroupName, group.Name)
propertyFields, appErr := th.App.SearchPropertyFields(th.Context, group.ID, model.PropertyFieldSearchOpts{PerPage: 100})
require.Nil(t, appErr)
require.Len(t, propertyFields, 11)
data, sysErr := th.Store.System().GetByName(contentFlaggingSetupDoneKey)
require.NoError(t, sysErr)
require.Equal(t, "v5", data.Value)
})
t.Run("the migration is idempotent", func(t *testing.T) {
th := Setup(t)
// Now we will remove the migration done key from systems table to allow the data migration to run again
_, err := th.Store.System().PermanentDeleteByName(contentFlaggingSetupDoneKey)
require.NoError(t, err)
// Run the content flagging data migration again
err = th.Server.doSetupContentFlaggingProperties()
require.NoError(t, err)
group, appErr := th.App.GetPropertyGroup(th.Context, model.ContentFlaggingGroupName)
require.Nil(t, appErr)
require.Equal(t, model.ContentFlaggingGroupName, group.Name)
propertyFields, appErr := th.App.SearchPropertyFields(th.Context, group.ID, model.PropertyFieldSearchOpts{PerPage: 100})
require.Nil(t, appErr)
require.Len(t, propertyFields, 11)
data, sysErr := th.Store.System().GetByName(contentFlaggingSetupDoneKey)
require.NoError(t, sysErr)
require.Equal(t, "v5", data.Value)
})
t.Run("concurrent runs tolerate update conflicts", func(t *testing.T) {
// Reproduce the CI failure: two server instances both read the same
// UpdateAt timestamps for the existing fields, then race to update them.
// The store's optimistic concurrency control means only one wins; the
// other must tolerate ErrConflict rather than crashing fatally.
th := Setup(t)
// Fields are already created by Setup. Clear the done key so both
// goroutines enter the migration body and take the update path.
_, err := th.Store.System().PermanentDeleteByName(contentFlaggingSetupDoneKey)
require.NoError(t, err)
const runners = 5
errs := make([]error, runners)
var wg sync.WaitGroup
wg.Add(runners)
for i := range runners {
go func() {
defer wg.Done()
errs[i] = th.Server.doSetupContentFlaggingProperties()
}()
}
wg.Wait()
for i, err := range errs {
require.NoError(t, err, "runner %d must not fail on concurrent update", i)
}
group, appErr := th.App.GetPropertyGroup(th.Context, model.ContentFlaggingGroupName)
require.Nil(t, appErr)
propertyFields, appErr := th.App.SearchPropertyFields(th.Context, group.ID, model.PropertyFieldSearchOpts{PerPage: 100})
require.Nil(t, appErr)
require.Len(t, propertyFields, 11)
data, sysErr := th.Store.System().GetByName(contentFlaggingSetupDoneKey)
require.NoError(t, sysErr)
require.Equal(t, contentFlaggingMigrationVersion, data.Value)
})
}
// clearCPABackfillMarker removes the System-key marker for the CPA display_name backfill
// so the migration body actually executes when called from a test. Setup(t) runs
// doAppMigrations which now includes the backfill; without clearing, the System key is
// already present and doSetupCPADisplayNameBackfill short-circuits at the idempotency check
// — the test would then pass for the wrong reason.
func clearCPABackfillMarker(t *testing.T, th *TestHelper) {
t.Helper()
_, err := th.Store.System().PermanentDeleteByName(cpaDisplayNameBackfillKey)
require.NoError(t, err, "failed to clear CPA backfill marker for test isolation")
}
func TestCPADisplayNameBackfill_NoExistingFields(t *testing.T) {
th := Setup(t)
clearCPABackfillMarker(t, th)
err := th.Server.doSetupCPADisplayNameBackfill(th.Context)
require.NoError(t, err)
data, sysErr := th.Store.System().GetByName(cpaDisplayNameBackfillKey)
require.NoError(t, sysErr)
require.NotNil(t, data)
require.Equal(t, "true", data.Value)
}
func TestCPADisplayNameBackfill_BackfillsMissing(t *testing.T) {
th := Setup(t)
// LicenseCheckHook gates writes to the access_control group on an
// Enterprise license; the seed CreatePropertyField calls below would
// otherwise be rejected with app.property.license_error.
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
clearCPABackfillMarker(t, th)
group, appErr := th.App.GetPropertyGroup(th.Context, model.AccessControlPropertyGroupName)
require.Nil(t, appErr)
// fieldA exercises the "display_name absent / empty in JSONB" case — the
// true idempotency boundary the migration is designed to fix.
fieldA, appErr := th.App.CreatePropertyField(th.Context, &model.PropertyField{
GroupID: group.ID,
Name: "department",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}, false, "")
require.Nil(t, appErr)
require.Empty(t, fieldA.Attrs[model.CustomProfileAttributesPropertyAttrsDisplayName],
"seed invariant: fieldA must have empty display_name")
fieldB, appErr := th.App.CreatePropertyField(th.Context, &model.PropertyField{
GroupID: group.ID,
Name: "job_title",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.CustomProfileAttributesPropertyAttrsDisplayName: "Job Title",
},
}, false, "")
require.Nil(t, appErr)
require.Equal(t, "Job Title", fieldB.Attrs[model.CustomProfileAttributesPropertyAttrsDisplayName],
"seed invariant: fieldB must have display_name set")
err := th.Server.doSetupCPADisplayNameBackfill(th.Context)
require.NoError(t, err)
updatedFieldA, appErr := th.App.GetPropertyField(th.Context, group.ID, fieldA.ID)
require.Nil(t, appErr)
require.Equal(t, "department", updatedFieldA.Attrs[model.CustomProfileAttributesPropertyAttrsDisplayName],
"fieldA: display_name must be backfilled to field name")
updatedFieldB, appErr := th.App.GetPropertyField(th.Context, group.ID, fieldB.ID)
require.Nil(t, appErr)
require.Equal(t, "Job Title", updatedFieldB.Attrs[model.CustomProfileAttributesPropertyAttrsDisplayName],
"fieldB: display_name must not be overwritten when already set")
data, sysErr := th.Store.System().GetByName(cpaDisplayNameBackfillKey)
require.NoError(t, sysErr)
require.NotNil(t, data)
require.Equal(t, "true", data.Value)
}
func TestCPADisplayNameBackfill_Idempotent(t *testing.T) {
th := Setup(t)
// LicenseCheckHook gates writes to the access_control group on an
// Enterprise license; the seed CreatePropertyField call below would
// otherwise be rejected with app.property.license_error.
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
clearCPABackfillMarker(t, th)
group, appErr := th.App.GetPropertyGroup(th.Context, model.AccessControlPropertyGroupName)
require.Nil(t, appErr)
seeded, appErr := th.App.CreatePropertyField(th.Context, &model.PropertyField{
GroupID: group.ID,
Name: "location",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
}, false, "")
require.Nil(t, appErr)
err := th.Server.doSetupCPADisplayNameBackfill(th.Context)
require.NoError(t, err)
data1, sysErr := th.Store.System().GetByName(cpaDisplayNameBackfillKey)
require.NoError(t, sysErr)
require.Equal(t, "true", data1.Value)
updatedAfterFirst, appErr := th.App.GetPropertyField(th.Context, group.ID, seeded.ID)
require.Nil(t, appErr)
require.Equal(t, "location", updatedAfterFirst.Attrs[model.CustomProfileAttributesPropertyAttrsDisplayName])
// Snapshot UpdateAt before the second run so we can prove the second run is a no-op
// at the DB-write level. PropertyField.UpdateAt is set to model.GetMillis() on every
// write, so a re-run would change it. (model.System exposes only Name + Value, so the
// System key cannot be probed the same way; the System-key SaveOrUpdate is gated by
// the same short-circuit that gates the field write, so the field check is sufficient.)
firstFieldUpdate := updatedAfterFirst.UpdateAt
// Second run: idempotency check fires immediately, returns nil without any DB writes.
err = th.Server.doSetupCPADisplayNameBackfill(th.Context)
require.NoError(t, err)
data2, sysErr := th.Store.System().GetByName(cpaDisplayNameBackfillKey)
require.NoError(t, sysErr)
require.Equal(t, "true", data2.Value)
updatedAfterSecond, appErr := th.App.GetPropertyField(th.Context, group.ID, seeded.ID)
require.Nil(t, appErr)
require.Equal(t, "location", updatedAfterSecond.Attrs[model.CustomProfileAttributesPropertyAttrsDisplayName],
"second run must not change display_name")
require.Equal(t, firstFieldUpdate, updatedAfterSecond.UpdateAt,
"second run must not re-write the field row")
}
func TestCPADisplayNameBackfill_BackfillsProtectedSourceOnlyField(t *testing.T) {
th := Setup(t)
// LicenseCheckHook gates writes to the access_control group on an
// Enterprise license. The seed below bypasses Create-side hooks via a
// direct store insert, but the backfill migration calls UpdatePropertyFields
// (unhooked) which still runs the version-match check; the license is
// nevertheless required by other CPA paths exercised across the suite.
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
clearCPABackfillMarker(t, th)
group, appErr := th.App.GetPropertyGroup(th.Context, model.AccessControlPropertyGroupName)
require.Nil(t, appErr)
groupID := group.ID
// Insert directly via the store so we bypass the property service's
// access-control routing (which would reject creating a protected
// source_only field from a non-plugin caller). ObjectType/TargetType are
// required so the field is recognized as PSAv2 and matches the group's
// version when the migration's UpdatePropertyFields runs.
field := &model.PropertyField{
GroupID: groupID,
Name: "uas_employee_id",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyAttrsProtected: true,
model.PropertyAttrsAccessMode: model.PropertyAccessModeSourceOnly,
model.PropertyAttrsSourcePluginID: "com.mattermost.uas-plugin",
// display_name intentionally omitted - this is the state the migration
// is designed to fix.
},
}
created, err := th.Store.PropertyField().Create(field)
require.NoError(t, err, "seed: protected source_only field must be insertable directly via the store")
err = th.Server.doSetupCPADisplayNameBackfill(th.Context)
require.NoError(t, err, "migration must succeed even when CPA fields are protected and owned by a plugin")
// Read back via the store directly to avoid any read-access filtering
// the AC layer might apply for a non-source-plugin caller.
got, err := th.Store.PropertyField().Get(th.Context, groupID, created.ID)
require.NoError(t, err)
require.Equal(t, "uas_employee_id", got.Attrs[model.CustomProfileAttributesPropertyAttrsDisplayName],
"display_name must be backfilled to the field name even on protected/source_only fields")
// Confirm the protection metadata was preserved untouched.
require.Equal(t, true, got.Attrs[model.PropertyAttrsProtected], "protected flag must be preserved")
require.Equal(t, model.PropertyAccessModeSourceOnly, got.Attrs[model.PropertyAttrsAccessMode], "access_mode must be preserved")
require.Equal(t, "com.mattermost.uas-plugin", got.Attrs[model.PropertyAttrsSourcePluginID], "source_plugin_id must be preserved")
data, sysErr := th.Store.System().GetByName(cpaDisplayNameBackfillKey)
require.NoError(t, sysErr)
require.NotNil(t, data)
require.Equal(t, "true", data.Value)
}
var expectedOSPlatformOptions = []string{"macos", "windows", "linux", "ios", "android"}
func sessionAttributeFieldByName(t *testing.T, th *TestHelper, groupID, name string) *model.PropertyField {
t.Helper()
fields, appErr := th.App.SearchPropertyFields(th.Context, groupID, model.PropertyFieldSearchOpts{PerPage: 100})
require.Nil(t, appErr)
for _, field := range fields {
if field.Name == name {
return field
}
}
require.FailNowf(t, "session attribute field not found", "field %q was not seeded", name)
return nil
}
func sessionAttributeOptions(t *testing.T, field *model.PropertyField) model.PropertyOptions[*model.PluginPropertyOption] {
t.Helper()
options, err := model.NewPropertyOptionsFromFieldAttrs[*model.PluginPropertyOption](field.Attrs[model.PropertyFieldAttributeOptions])
require.NoError(t, err)
return options
}
func sessionAttributeOptionNames(t *testing.T, field *model.PropertyField) []string {
t.Helper()
options := sessionAttributeOptions(t, field)
names := make([]string, 0, len(options))
for _, option := range options {
names = append(names, option.GetName())
}
return names
}
func schemaSessionAttributeOptionNames(t *testing.T, groupID, name string) []string {
t.Helper()
for _, field := range model.SessionAttributeSystemFields(groupID) {
if field.Name == name {
return sessionAttributeOptionNames(t, field)
}
}
require.FailNowf(t, "session attribute field not found", "field %q is not declared by the schema", name)
return nil
}
func sessionAttributeOptionIDsByName(t *testing.T, field *model.PropertyField) map[string]string {
t.Helper()
options := sessionAttributeOptions(t, field)
idsByName := make(map[string]string, len(options))
for _, option := range options {
idsByName[option.GetName()] = option.GetID()
}
return idsByName
}
func TestDoSetupSessionAttributesProperties(t *testing.T) {
expectedFieldCount := len(model.SessionAttributeSystemFields("group-id"))
t.Run("fresh install seeds the group and fields", func(t *testing.T) {
th := Setup(t)
group, appErr := th.App.GetPropertyGroup(th.Context, model.SessionAttributesPropertyGroupName)
require.Nil(t, appErr)
require.NotNil(t, group)
require.Equal(t, model.SessionAttributesPropertyGroupName, group.Name)
fields, appErr := th.App.SearchPropertyFields(th.Context, group.ID, model.PropertyFieldSearchOpts{PerPage: 100})
require.Nil(t, appErr)
require.Len(t, fields, expectedFieldCount)
fieldsByName := make(map[string]*model.PropertyField, len(fields))
for _, field := range fields {
fieldsByName[field.Name] = field
require.Equal(t, model.PropertyFieldObjectTypeSession, field.ObjectType, "field %q object type", field.Name)
require.Equal(t, string(model.PropertyFieldTargetLevelSystem), field.TargetType, "field %q target type", field.Name)
require.False(t, field.Protected, "field %q must not be protected", field.Name)
require.NotNil(t, field.PermissionField)
require.Equal(t, model.PermissionLevelSysadmin, *field.PermissionField, "field %q permission_field", field.Name)
require.NotNil(t, field.PermissionValues)
require.Equal(t, model.PermissionLevelSysadmin, *field.PermissionValues, "field %q permission_values", field.Name)
require.Equal(t, false, field.Attrs["enabled"], "field %q must seed disabled", field.Name)
require.NotEmpty(t, field.Attrs[model.SAAttrDisplayName], "field %q must seed a display name", field.Name)
}
ipField := fieldsByName[model.SessionAttributesPropertyFieldIPAddress]
require.NotNil(t, ipField)
require.Equal(t, model.PropertyFieldTypeText, ipField.Type)
networkField := fieldsByName[model.SessionAttributesPropertyFieldNetworkInterfaceType]
require.NotNil(t, networkField)
require.Equal(t, model.PropertyFieldTypeSelect, networkField.Type)
require.NotNil(t, networkField.Attrs[model.PropertyFieldAttributeOptions])
// The typed attrs must survive the DB round trip so the app reads back what it seeded.
saField, err := model.SAFieldFromPropertyField(networkField)
require.NoError(t, err)
require.Equal(t, model.SessionAttributesDisplayNameNetworkInterfaceType, saField.Attrs.DisplayName)
require.Equal(t, model.SessionAttributeDefaultTTLNetworkIdentity, saField.Attrs.TTLSeconds)
require.Equal(t, model.SessionAttributeDefaultGraceNetworkIdentity, saField.Attrs.GracePeriodSeconds)
require.ElementsMatch(t,
[]string{model.SessionAttributePlatformDesktop, model.SessionAttributePlatformMobile},
saField.Attrs.Platforms,
)
})
t.Run("os_platform seeds as a select with the known platform values", func(t *testing.T) {
th := Setup(t)
group, appErr := th.App.GetPropertyGroup(th.Context, model.SessionAttributesPropertyGroupName)
require.Nil(t, appErr)
field := sessionAttributeFieldByName(t, th, group.ID, model.SessionAttributesPropertyFieldOSPlatform)
require.Equal(t, model.PropertyFieldTypeSelect, field.Type)
require.Equal(t, expectedOSPlatformOptions, sessionAttributeOptionNames(t, field))
require.True(t, model.IsValidSessionAttributeValue(field, "windows"))
require.False(t, model.IsValidSessionAttributeValue(field, "darwin"))
})
t.Run("converts a legacy text os_platform field into a select", func(t *testing.T) {
th := Setup(t)
group, appErr := th.App.GetPropertyGroup(th.Context, model.SessionAttributesPropertyGroupName)
require.Nil(t, appErr)
// Restore the pre-conversion shape a server upgrade would find: free
// text with no options. Written with a system-caller context so
// SessionAttributesHook treats it as a system caller, the same way the
// seed itself does.
field := sessionAttributeFieldByName(t, th, group.ID, model.SessionAttributesPropertyFieldOSPlatform)
field.Type = model.PropertyFieldTypeText
delete(field.Attrs, model.PropertyFieldAttributeOptions)
_, _, _, err := th.Server.propertyService.UpdatePropertyFields(properties.SystemCallerContext(th.Context), group.ID, []*model.PropertyField{field})
require.NoError(t, err)
require.NoError(t, th.Server.doSetupSessionAttributesProperties())
converted := sessionAttributeFieldByName(t, th, group.ID, model.SessionAttributesPropertyFieldOSPlatform)
require.Equal(t, model.PropertyFieldTypeSelect, converted.Type)
require.Equal(t, expectedOSPlatformOptions, sessionAttributeOptionNames(t, converted))
require.True(t, model.IsValidSessionAttributeValue(converted, "windows"))
})
t.Run("re-seeding preserves select option IDs", func(t *testing.T) {
th := Setup(t)
group, appErr := th.App.GetPropertyGroup(th.Context, model.SessionAttributesPropertyGroupName)
require.Nil(t, appErr)
before := sessionAttributeOptionIDsByName(t, sessionAttributeFieldByName(t, th, group.ID, model.SessionAttributesPropertyFieldOSPlatform))
require.Len(t, before, len(expectedOSPlatformOptions))
require.NoError(t, th.Server.doSetupSessionAttributesProperties())
after := sessionAttributeOptionIDsByName(t, sessionAttributeFieldByName(t, th, group.ID, model.SessionAttributesPropertyFieldOSPlatform))
require.Equal(t, before, after, "re-seeding must not regenerate option IDs")
})
t.Run("adds newly declared options to an already-seeded select", func(t *testing.T) {
th := Setup(t)
group, appErr := th.App.GetPropertyGroup(th.Context, model.SessionAttributesPropertyGroupName)
require.Nil(t, appErr)
// Restore what an upgrading server finds: the field as it was seeded
// before "Android" joined the schema.
field := sessionAttributeFieldByName(t, th, group.ID, model.SessionAttributesPropertyFieldUserAgentPlatform)
persisted := sessionAttributeOptions(t, field)
trimmed := make([]any, 0, len(persisted))
for _, option := range persisted {
if option.GetName() == "Android" {
continue
}
trimmed = append(trimmed, map[string]any{"id": option.GetID(), "name": option.GetName()})
}
require.Len(t, trimmed, len(persisted)-1)
field.Attrs[model.PropertyFieldAttributeOptions] = trimmed
// System-caller context, same as the seed and the sibling upgrade
// subtests: SessionAttributesHook otherwise reads the existing field
// through RequestContextWithMaster, which panics on a nil rctx.
_, _, _, err := th.Server.propertyService.UpdatePropertyFields(properties.SystemCallerContext(th.Context), group.ID, []*model.PropertyField{field})
require.NoError(t, err)
before := sessionAttributeOptionIDsByName(t, sessionAttributeFieldByName(t, th, group.ID, model.SessionAttributesPropertyFieldUserAgentPlatform))
require.NotContains(t, before, "Android")
require.NoError(t, th.Server.doSetupSessionAttributesProperties())
updated := sessionAttributeFieldByName(t, th, group.ID, model.SessionAttributesPropertyFieldUserAgentPlatform)
require.Equal(t,
schemaSessionAttributeOptionNames(t, group.ID, model.SessionAttributesPropertyFieldUserAgentPlatform),
sessionAttributeOptionNames(t, updated),
)
require.True(t, model.IsValidSessionAttributeValue(updated, "Android"))
after := sessionAttributeOptionIDsByName(t, updated)
require.NotEmpty(t, after["Android"], "the new option must be assigned an ID")
for name, id := range before {
require.Equal(t, id, after[name], "option %q must keep its ID", name)
}
})
t.Run("re-running is idempotent", func(t *testing.T) {
th := Setup(t)
group, appErr := th.App.GetPropertyGroup(th.Context, model.SessionAttributesPropertyGroupName)
require.Nil(t, appErr)
before, appErr := th.App.SearchPropertyFields(th.Context, group.ID, model.PropertyFieldSearchOpts{PerPage: 100})
require.Nil(t, appErr)
require.Len(t, before, expectedFieldCount)
err := th.Server.doSetupSessionAttributesProperties()
require.NoError(t, err)
after, appErr := th.App.SearchPropertyFields(th.Context, group.ID, model.PropertyFieldSearchOpts{PerPage: 100})
require.Nil(t, appErr)
require.Len(t, after, expectedFieldCount, "re-running must not create duplicate fields")
})
t.Run("backfills operators on legacy fields missing attrs.operators", func(t *testing.T) {
th := Setup(t)
group, appErr := th.App.GetPropertyGroup(th.Context, model.SessionAttributesPropertyGroupName)
require.Nil(t, appErr)
field := sessionAttributeFieldByName(t, th, group.ID, model.SessionAttributesPropertyFieldIPAddress)
delete(field.Attrs, model.NativeAttributeAttrOperators)
_, _, _, err := th.Server.propertyService.UpdatePropertyFields(properties.SystemCallerContext(th.Context), group.ID, []*model.PropertyField{field})
require.NoError(t, err)
require.NoError(t, th.Server.doSetupSessionAttributesProperties())
updated := sessionAttributeFieldByName(t, th, group.ID, model.SessionAttributesPropertyFieldIPAddress)
require.NotNil(t, updated.Attrs[model.NativeAttributeAttrOperators])
})
t.Run("concurrent runs tolerate update conflicts", func(t *testing.T) {
th := Setup(t)
// Fields already exist from Setup. Every goroutine runs the seed body
// and races on the same UpdateAt timestamps. Only one wins; the rest
// must tolerate the resulting ErrConflict rather than failing.
const runners = 5
errs := make([]error, runners)
var wg sync.WaitGroup
wg.Add(runners)
for i := range runners {
go func() {
defer wg.Done()
errs[i] = th.Server.doSetupSessionAttributesProperties()
}()
}
wg.Wait()
for i, err := range errs {
require.NoError(t, err, "runner %d must not fail on concurrent update", i)
}
group, appErr := th.App.GetPropertyGroup(th.Context, model.SessionAttributesPropertyGroupName)
require.Nil(t, appErr)
fields, appErr := th.App.SearchPropertyFields(th.Context, group.ID, model.PropertyFieldSearchOpts{PerPage: 100})
require.Nil(t, appErr)
require.Len(t, fields, expectedFieldCount)
})
}
func TestDoSetupBoardsProperties(t *testing.T) {
t.Run("should register property group and fields", func(t *testing.T) {
th := Setup(t)
group, appErr := th.App.GetPropertyGroup(th.Context, model.BoardsPropertyGroupName)
require.Nil(t, appErr)
require.NotNil(t, group)
require.Equal(t, model.BoardsPropertyGroupName, group.Name)
propertyFields, appErr := th.App.SearchPropertyFields(th.Context, group.ID, model.PropertyFieldSearchOpts{PerPage: 100})
require.Nil(t, appErr)
require.Len(t, propertyFields, 2)
fieldsByName := map[string]*model.PropertyField{}
for _, f := range propertyFields {
fieldsByName[f.Name] = f
}
assignee := fieldsByName[model.BoardsPropertyFieldAssignee]
require.NotNil(t, assignee)
require.Equal(t, model.PropertyFieldTypeUser, assignee.Type)
require.True(t, assignee.Protected)
status := fieldsByName[model.BoardsPropertyFieldStatus]
require.NotNil(t, status)
require.Equal(t, model.PropertyFieldTypeSelect, status.Type)
require.True(t, status.Protected)
require.NotNil(t, status.Attrs["options"])
// v2 seeds a default colour per status option. Verify the three seeded
// options carry the expected colours so kanban columns render with
// their intended palette out of the box.
assertStatusColors(t, status.Attrs, map[string]string{
model.BoardsStatusOptionTodo: model.BoardsStatusColorTodo,
model.BoardsStatusOptionInProgress: model.BoardsStatusColorInProgress,
model.BoardsStatusOptionComplete: model.BoardsStatusColorComplete,
})
data, sysErr := th.Store.System().GetByName(boardsPropertySetupDoneKey)
require.NoError(t, sysErr)
require.Equal(t, boardsPropertyMigrationVersion, data.Value)
})
t.Run("the migration is idempotent", func(t *testing.T) {
th := Setup(t)
_, err := th.Store.System().PermanentDeleteByName(boardsPropertySetupDoneKey)
require.NoError(t, err)
err = th.Server.doSetupBoardsProperties()
require.NoError(t, err)
group, appErr := th.App.GetPropertyGroup(th.Context, model.BoardsPropertyGroupName)
require.Nil(t, appErr)
require.Equal(t, model.BoardsPropertyGroupName, group.Name)
propertyFields, appErr := th.App.SearchPropertyFields(th.Context, group.ID, model.PropertyFieldSearchOpts{PerPage: 100})
require.Nil(t, appErr)
require.Len(t, propertyFields, 2)
data, sysErr := th.Store.System().GetByName(boardsPropertySetupDoneKey)
require.NoError(t, sysErr)
require.Equal(t, boardsPropertyMigrationVersion, data.Value)
})
t.Run("upgrading v1 → v2 layers colours onto existing options without rewriting their IDs", func(t *testing.T) {
// Setup() already runs v2 cleanly. Simulate a workspace that was
// previously seeded with v1 (no colours) by stripping every colour
// from the Status options and rolling the system flag back to v1.
th := Setup(t)
group, appErr := th.App.GetPropertyGroup(th.Context, model.BoardsPropertyGroupName)
require.Nil(t, appErr)
fields, appErr := th.App.SearchPropertyFields(th.Context, group.ID, model.PropertyFieldSearchOpts{PerPage: 100})
require.Nil(t, appErr)
var statusBefore *model.PropertyField
for _, f := range fields {
if f.Name == model.BoardsPropertyFieldStatus {
statusBefore = f
break
}
}
require.NotNil(t, statusBefore)
// Snapshot the option IDs assigned by the v1 seed run so we can verify
// they survive the v2 upgrade.
idsBefore := optionIDsByName(t, statusBefore.Attrs)
require.Len(t, idsBefore, 3)
for _, id := range idsBefore {
require.NotEmpty(t, id)
}
// Strip colours to simulate the v1 shape, then rewind the version flag.
stripped := stripStatusColors(t, statusBefore.Attrs)
statusBefore.Attrs = stripped
_, _, _, updateErr := th.Server.propertyService.UpdatePropertyFields(th.Context, group.ID, []*model.PropertyField{statusBefore})
require.NoError(t, updateErr)
sysErr := th.Store.System().SaveOrUpdate(&model.System{Name: boardsPropertySetupDoneKey, Value: "v1"})
require.NoError(t, sysErr)
// Run the migration again — should layer colours back on without
// changing option IDs.
require.NoError(t, th.Server.doSetupBoardsProperties())
fields, appErr = th.App.SearchPropertyFields(th.Context, group.ID, model.PropertyFieldSearchOpts{PerPage: 100})
require.Nil(t, appErr)
var statusAfter *model.PropertyField
for _, f := range fields {
if f.Name == model.BoardsPropertyFieldStatus {
statusAfter = f
break
}
}
require.NotNil(t, statusAfter)
assertStatusColors(t, statusAfter.Attrs, map[string]string{
model.BoardsStatusOptionTodo: model.BoardsStatusColorTodo,
model.BoardsStatusOptionInProgress: model.BoardsStatusColorInProgress,
model.BoardsStatusOptionComplete: model.BoardsStatusColorComplete,
})
idsAfter := optionIDsByName(t, statusAfter.Attrs)
require.Equal(t, idsBefore, idsAfter, "v2 upgrade must preserve every existing option ID")
})
}
func assertStatusColors(t *testing.T, attrs model.StringInterface, want map[string]string) {
t.Helper()
got := map[string]string{}
encoded, err := json.Marshal(attrs["options"])
require.NoError(t, err)
var options []map[string]any
require.NoError(t, json.Unmarshal(encoded, &options))
for _, opt := range options {
name, _ := opt["name"].(string)
color, _ := opt["color"].(string)
if name != "" {
got[name] = color
}
}
require.Equal(t, want, got)
}
func optionIDsByName(t *testing.T, attrs model.StringInterface) map[string]string {
t.Helper()
out := map[string]string{}
encoded, err := json.Marshal(attrs["options"])
require.NoError(t, err)
var options []map[string]any
require.NoError(t, json.Unmarshal(encoded, &options))
for _, opt := range options {
name, _ := opt["name"].(string)
id, _ := opt["id"].(string)
if name != "" {
out[name] = id
}
}
return out
}
func stripStatusColors(t *testing.T, attrs model.StringInterface) model.StringInterface {
t.Helper()
encoded, err := json.Marshal(attrs["options"])
require.NoError(t, err)
var options []map[string]any
require.NoError(t, json.Unmarshal(encoded, &options))
for _, opt := range options {
delete(opt, "color")
}
out := make(model.StringInterface, len(attrs))
maps.Copy(out, attrs)
out["options"] = options
return out
}
// lockToMasterSpyStore records when doAppMigrations locks and unlocks the store
// relative to the store access the migrations themselves make.
type lockToMasterSpyStore struct {
store.Store
mu sync.Mutex
events []string
}
func (s *lockToMasterSpyStore) record(event string) {
s.mu.Lock()
defer s.mu.Unlock()
s.events = append(s.events, event)
}
func (s *lockToMasterSpyStore) snapshot() []string {
s.mu.Lock()
defer s.mu.Unlock()
return append([]string(nil), s.events...)
}
func (s *lockToMasterSpyStore) LockToMaster() {
s.record("lock")
s.Store.LockToMaster()
}
func (s *lockToMasterSpyStore) UnlockFromMaster() {
s.record("unlock")
s.Store.UnlockFromMaster()
}
func (s *lockToMasterSpyStore) System() store.SystemStore {
s.record("system")
return s.Store.System()
}
// Guard, not a reproduction: storetest configures no replicas, so GetReplica already
// returns master and the race cannot be staged here. It pins that doAppMigrations
// brackets its work in the master lock; it does not exercise the routing of individual
// reads, as propertyService holds store handles captured before the spy wraps them.
func TestDoAppMigrationsRunsLockedToMaster(t *testing.T) {
th := Setup(t)
spy := &lockToMasterSpyStore{Store: th.Server.Store()}
th.Server.SetStore(spy)
t.Cleanup(func() { th.Server.SetStore(spy.Store) })
th.Server.doAppMigrations()
events := spy.snapshot()
require.GreaterOrEqual(t, len(events), 3, "expected migrations to access the store between lock and unlock")
require.Equal(t, "lock", events[0], "migrations must lock to master before touching the store")
require.Equal(t, "unlock", events[len(events)-1], "migrations must release the lock once finished")
require.Contains(t, events[1:len(events)-1], "system", "migration store access must happen while locked to master")
}