Files
team/server/channels/store/sqlstore/migration_000185_test.go
ce5644223a [MM-68400] Four plugin hooks and ChannelGuard enforcement (#36152)
* allow workflow_dispatch trigger for Server CI (for plugins CI)

* [MM-68402] MBE Phase 2: declare four generic plugin hooks (#36291)

* new hooks-only phase 2

* remove ChannelWillBeMoved

* remove RecapWillBeProcessed and MessageWillBeRewrittenByAI

Drop the AI/recap hooks from the new-hook surface; AI-LLM paths
remain uncovered in tech preview and are documented as residuals.

* [MM-68403] MBE Phase 3: ChannelGuards primitive (storage + cache + plugin API) (#36365)

* phase 3

* phase 3: register ChannelGuard mock in test setup helper

NewChannels' startup-time call to reloadGuardCache invokes
s.ChannelGuard().GetAll(); without an expectation on the mock store,
every test that sets up the server with GetMockStoreForSetupFunctions
panics during init.

* phase 3: register ChannelGuard mock in retrylayer test

retrylayer.New walks every store getter to wrap it; without the mock
expectation on ChannelGuard, TestRetry panics during layer construction.

* use rctx properly in the store methods

* phase 3: match rctx arg in testlib ChannelGuard mock

GetAll now takes request.CTX, so the testify expectation must include
mock.Anything; otherwise the call panics under the mocked store.

* phase 3: set api.ctx in TestChannelGuardLowercaseNormalization

The test constructs PluginAPI directly without a ctx, which used to
work when App.RegisterChannelGuard built its own EmptyContext. Now
that the App methods take rctx from the caller, the nil ctx panics
inside RequestContextWithMaster.

* [MM-68404] MBE Phase 4: App-layer plugin hook wiring (#36407)

* phase 4

* Fix nil rctx in TestChannelGuardLowercaseNormalization

The PluginAPI struct literal was missing ctx: rctx after a refactor
moved the rctx declaration below the struct construction, leaving
api.ctx as nil. This caused a nil pointer dereference in reloadGuardCache
when RegisterChannelGuard called store.RequestContextWithMaster(nil).

* Remove ChannelWillBeMoved hook call from MoveChannel (phase 4)

The hook and its ID were removed from mbe-phase-2 but the call site in
MoveChannel and its i18n string were not cleaned up during the rebase.

* remove channel will be moved test

* Remove RecapWillBeProcessed and MessageWillBeRewrittenByAI hook calls (phase 4)

The hooks and their IDs were removed from mbe-phase-2 but the call sites
in ProcessRecapChannel and RewriteMessage, their i18n strings, and their
tests were not cleaned up during the rebase.

* Revert channel_id plumbing on rewrite endpoint (phase 4)

The channel_id field on RewriteRequest was added in phase 4 to feed the
synthetic post passed to MessageWillBeRewrittenByAI. With that hook
removed from mbe-phase-2, channel_id has no consumer; revert the field,
the api4 validation, the app.RewriteMessage parameter, and the
corresponding webapp client + hook plumbing.

* [MM-68555] MBE Phase 5: Channel-guard enforcement + two-phase dispatch (#36473)

* phase 5

* Bake plugin counter-file paths into source instead of env vars

t.Setenv panics when an ancestor test calls t.Parallel, so the two
channel-guard tests broke under ENABLE_FULLY_PARALLEL_TESTS in CI.
Build each plugin source per-subtest with its temp file path embedded
as a Go literal — same pattern as TestPluginUploadsAPI.

* Remove guarded helpers and tests for dropped hooks (phase 5)

The runGuardedRecapWillBeProcessed and runGuardedMessageWillBeRewrittenByAI
helpers were never wired (their app-layer call sites were already removed
in the phase-4 cleanup), and the corresponding sub-tests across panic /
allow / reject / partial plugins reference hooks that no longer exist.

* [MM-68405] MBE Phase 6: fire MessagesWillBeConsumed on the edit path (#36475)

---------

---------

---------

---------

* rebase onto master

---------

Co-authored-by: Hanzo Dev <[email protected]>
2026-05-21 18:16:05 +00:00

332 lines
14 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/db"
)
func readMigrationSQL(t *testing.T, filename string) string {
t.Helper()
data, err := db.Assets().ReadFile("migrations/postgres/" + filename)
require.NoError(t, err, "failed to read migration file %s", filename)
return string(data)
}
func TestMigration000185(t *testing.T) {
logger := mlog.CreateTestLogger(t)
settings, err := makeSqlSettings(model.DatabaseDriverPostgres)
if err != nil {
t.Skip(err)
}
store, err := New(*settings, logger, nil)
require.NoError(t, err)
defer store.Close()
master := store.GetMaster()
upSQL := readMigrationSQL(t, "000176_migrate_cpa_to_access_control.up.sql")
downSQL := readMigrationSQL(t, "000176_migrate_cpa_to_access_control.down.sql")
// Insert a group simulating pre-migration CPA state.
groupID := model.NewId()
_, err = master.Exec("INSERT INTO PropertyGroups (ID, Name) VALUES (?, ?)", groupID, "custom_profile_attributes")
require.NoError(t, err)
t.Cleanup(func() {
master.Exec("DELETE FROM PropertyValues WHERE GroupID = ?", groupID) //nolint:errcheck
master.Exec("DELETE FROM PropertyFields WHERE GroupID = ?", groupID) //nolint:errcheck
master.Exec("DELETE FROM PropertyGroups WHERE ID = ?", groupID) //nolint:errcheck
})
now := model.GetMillis()
// Insert active fields with old format (no ObjectType, no permissions).
// fieldID1 and fieldID2 are non-managed; fieldID3 is admin-managed.
fieldID1 := model.NewId()
fieldID2 := model.NewId()
fieldID3 := model.NewId()
for _, f := range []struct {
id string
name string
ftype string
attrs string
}{
{fieldID1, "Text Field", "text", `{"visibility":"always","sort_order":1}`},
{fieldID2, "Select Field", "select", `{"options":[{"id":"opt1","name":"Option 1"}]}`},
{fieldID3, "Admin Managed Field", "text", `{"visibility":"always","sort_order":3,"managed":"admin"}`},
} {
_, err = master.Exec(
`INSERT INTO PropertyFields
(ID, GroupID, Name, Type, Attrs, TargetID, TargetType, ObjectType, CreateAt, UpdateAt, DeleteAt, Protected)
VALUES (?, ?, ?, ?, ?::jsonb, '', '', '', ?, ?, 0, false)`,
f.id, groupID, f.name, f.ftype, f.attrs, now, now,
)
require.NoError(t, err, "inserting field %s", f.name)
}
// Insert a soft-deleted field to verify all fields are migrated.
deletedFieldID := model.NewId()
_, err = master.Exec(
`INSERT INTO PropertyFields
(ID, GroupID, Name, Type, Attrs, TargetID, TargetType, ObjectType, CreateAt, UpdateAt, DeleteAt, Protected)
VALUES (?, ?, 'Deleted Field', 'text', '{}'::jsonb, '', '', '', ?, ?, ?, false)`,
deletedFieldID, groupID, now, now, now,
)
require.NoError(t, err)
// Insert a property value.
valueID := model.NewId()
targetUserID := model.NewId()
_, err = master.Exec(
`INSERT INTO PropertyValues
(ID, TargetID, TargetType, GroupID, FieldID, Value, CreateAt, UpdateAt, DeleteAt)
VALUES (?, ?, 'user', ?, ?, '"hello"'::jsonb, ?, ?, 0)`,
valueID, targetUserID, groupID, fieldID1, now, now,
)
require.NoError(t, err)
// ---- Run UP migration ----
_, err = master.ExecNoTimeout(upSQL)
require.NoError(t, err, "up migration should succeed")
// Verify: group renamed.
var groupName string
require.NoError(t, master.Get(&groupName, "SELECT Name FROM PropertyGroups WHERE ID = ?", groupID))
assert.Equal(t, "access_control", groupName)
// Verify: all fields (including soft-deleted) have new metadata.
// Non-managed fields get PermissionValues = 'member'.
// Admin-managed fields get PermissionValues = 'sysadmin'.
for _, tc := range []struct {
id string
label string
expectedPermissionValues string
}{
{fieldID1, "non-managed text field", "member"},
{fieldID2, "non-managed select field", "member"},
{fieldID3, "admin-managed field", "sysadmin"},
{deletedFieldID, "soft-deleted non-managed field", "member"},
} {
var f struct {
ObjectType string `db:"objecttype"`
TargetType string `db:"targettype"`
PermissionField sql.NullString `db:"permissionfield"`
PermissionValues sql.NullString `db:"permissionvalues"`
PermissionOptions sql.NullString `db:"permissionoptions"`
}
require.NoError(t, master.Get(&f, "SELECT ObjectType, TargetType, PermissionField, PermissionValues, PermissionOptions FROM PropertyFields WHERE ID = ?", tc.id))
assert.Equal(t, "user", f.ObjectType, "%s ObjectType", tc.label)
assert.Equal(t, "system", f.TargetType, "%s TargetType", tc.label)
assert.True(t, f.PermissionField.Valid, "%s PermissionField should be set", tc.label)
assert.Equal(t, "sysadmin", f.PermissionField.String, "%s PermissionField", tc.label)
assert.True(t, f.PermissionValues.Valid, "%s PermissionValues should be set", tc.label)
assert.Equal(t, tc.expectedPermissionValues, f.PermissionValues.String, "%s PermissionValues", tc.label)
assert.True(t, f.PermissionOptions.Valid, "%s PermissionOptions should be set", tc.label)
assert.Equal(t, "sysadmin", f.PermissionOptions.String, "%s PermissionOptions", tc.label)
}
// Verify: property value is unchanged (GroupID still references the same ID).
var val struct {
GroupID string `db:"groupid"`
TargetID string `db:"targetid"`
TargetType string `db:"targettype"`
}
require.NoError(t, master.Get(&val, "SELECT GroupID, TargetID, TargetType FROM PropertyValues WHERE ID = ?", valueID))
assert.Equal(t, groupID, val.GroupID, "value GroupID should be unchanged")
assert.Equal(t, targetUserID, val.TargetID, "value TargetID should be unchanged")
assert.Equal(t, "user", val.TargetType, "value TargetType should be unchanged")
// Verify: AttributeView exists and includes the ObjectType filter (user-type fields only).
var viewDef string
err = master.Get(&viewDef, "SELECT definition FROM pg_matviews WHERE matviewname = 'attributeview'")
require.NoError(t, err, "AttributeView should exist")
assert.Contains(t, viewDef, "pf.objecttype", "view definition should filter by pf.ObjectType")
// Verify: materialized view contains expected data after refresh.
_, err = master.ExecNoTimeout("REFRESH MATERIALIZED VIEW AttributeView")
require.NoError(t, err, "refreshing AttributeView should succeed")
var viewRow struct {
GroupID string `db:"groupid"`
TargetID string `db:"targetid"`
TargetType string `db:"targettype"`
Attributes []byte `db:"attributes"`
}
err = master.Get(&viewRow, "SELECT GroupID, TargetID, TargetType, Attributes FROM AttributeView WHERE TargetID = ?", targetUserID)
require.NoError(t, err, "AttributeView should contain a row for the target user")
assert.Equal(t, groupID, viewRow.GroupID)
assert.Equal(t, targetUserID, viewRow.TargetID)
assert.Equal(t, "user", viewRow.TargetType)
// The text field value "hello" should appear under the field name "Text Field".
var attrs map[string]json.RawMessage
require.NoError(t, json.Unmarshal(viewRow.Attributes, &attrs))
assert.JSONEq(t, `"hello"`, string(attrs["Text Field"]), "text field value should be materialized")
// ---- Run DOWN migration ----
_, err = master.ExecNoTimeout(downSQL)
require.NoError(t, err, "down migration should succeed")
// Verify: group name reverted.
require.NoError(t, master.Get(&groupName, "SELECT Name FROM PropertyGroups WHERE ID = ?", groupID))
assert.Equal(t, "custom_profile_attributes", groupName)
// Verify: fields reverted.
for _, fid := range []string{fieldID1, fieldID2, fieldID3, deletedFieldID} {
var f struct {
ObjectType string `db:"objecttype"`
TargetType string `db:"targettype"`
PermissionField sql.NullString `db:"permissionfield"`
PermissionValues sql.NullString `db:"permissionvalues"`
PermissionOptions sql.NullString `db:"permissionoptions"`
}
require.NoError(t, master.Get(&f, "SELECT ObjectType, TargetType, PermissionField, PermissionValues, PermissionOptions FROM PropertyFields WHERE ID = ?", fid))
assert.Equal(t, "", f.ObjectType, "field %s ObjectType should revert", fid)
assert.Equal(t, "", f.TargetType, "field %s TargetType should revert", fid)
assert.False(t, f.PermissionField.Valid, "field %s PermissionField should be NULL", fid)
assert.False(t, f.PermissionValues.Valid, "field %s PermissionValues should be NULL", fid)
assert.False(t, f.PermissionOptions.Valid, "field %s PermissionOptions should be NULL", fid)
}
// Verify: value still unchanged after down migration.
require.NoError(t, master.Get(&val, "SELECT GroupID, TargetID, TargetType FROM PropertyValues WHERE ID = ?", valueID))
assert.Equal(t, groupID, val.GroupID, "value GroupID should remain unchanged after down")
}
func TestMigration000185DownPreservesNonUserFields(t *testing.T) {
logger := mlog.CreateTestLogger(t)
settings, err := makeSqlSettings(model.DatabaseDriverPostgres)
if err != nil {
t.Skip(err)
}
store, err := New(*settings, logger, nil)
require.NoError(t, err)
defer store.Close()
master := store.GetMaster()
upSQL := readMigrationSQL(t, "000176_migrate_cpa_to_access_control.up.sql")
downSQL := readMigrationSQL(t, "000176_migrate_cpa_to_access_control.down.sql")
groupID := model.NewId()
_, err = master.Exec("INSERT INTO PropertyGroups (ID, Name) VALUES (?, ?)", groupID, "custom_profile_attributes")
require.NoError(t, err)
t.Cleanup(func() {
master.Exec("DELETE FROM PropertyFields WHERE GroupID = ?", groupID) //nolint:errcheck
master.Exec("DELETE FROM PropertyGroups WHERE ID = ?", groupID) //nolint:errcheck
})
now := model.GetMillis()
// Insert a legacy user field that the up migration will touch.
userFieldID := model.NewId()
_, err = master.Exec(
`INSERT INTO PropertyFields
(ID, GroupID, Name, Type, Attrs, TargetID, TargetType, ObjectType, CreateAt, UpdateAt, DeleteAt, Protected)
VALUES (?, ?, 'Legacy User Field', 'text', '{}'::jsonb, '', '', '', ?, ?, 0, false)`,
userFieldID, groupID, now, now,
)
require.NoError(t, err)
// Run UP migration — legacy user field gets ObjectType='user', TargetType='system'.
_, err = master.ExecNoTimeout(upSQL)
require.NoError(t, err, "up migration should succeed")
// Simulate a post-migration channel-scoped field created via the
// generic property API against the (now renamed) access_control
// group.
channelFieldID := model.NewId()
channelTargetID := model.NewId()
_, err = master.Exec(
`INSERT INTO PropertyFields
(ID, GroupID, Name, Type, Attrs, TargetID, TargetType, ObjectType, PermissionField, PermissionValues, PermissionOptions, CreateAt, UpdateAt, DeleteAt, Protected)
VALUES (?, ?, 'Channel Classification', 'select', '{}'::jsonb, ?, 'channel', 'channel', 'sysadmin', 'member', 'sysadmin', ?, ?, 0, false)`,
channelFieldID, groupID, channelTargetID, now, now,
)
require.NoError(t, err)
// Run DOWN migration — must revert only user/system fields, not the channel one.
_, err = master.ExecNoTimeout(downSQL)
require.NoError(t, err, "down migration should succeed")
// The original user field reverts to legacy metadata.
var userField struct {
ObjectType string `db:"objecttype"`
TargetType string `db:"targettype"`
PermissionField sql.NullString `db:"permissionfield"`
PermissionValues sql.NullString `db:"permissionvalues"`
PermissionOptions sql.NullString `db:"permissionoptions"`
}
require.NoError(t, master.Get(&userField, "SELECT ObjectType, TargetType, PermissionField, PermissionValues, PermissionOptions FROM PropertyFields WHERE ID = ?", userFieldID))
assert.Equal(t, "", userField.ObjectType, "user field ObjectType should revert")
assert.Equal(t, "", userField.TargetType, "user field TargetType should revert")
assert.False(t, userField.PermissionField.Valid, "user field PermissionField should be NULL")
// The post-migration channel field keeps its PSAv2 metadata intact.
var channelField struct {
ObjectType string `db:"objecttype"`
TargetType string `db:"targettype"`
TargetID string `db:"targetid"`
PermissionField sql.NullString `db:"permissionfield"`
PermissionValues sql.NullString `db:"permissionvalues"`
PermissionOptions sql.NullString `db:"permissionoptions"`
}
require.NoError(t, master.Get(&channelField, "SELECT ObjectType, TargetType, TargetID, PermissionField, PermissionValues, PermissionOptions FROM PropertyFields WHERE ID = ?", channelFieldID))
assert.Equal(t, "channel", channelField.ObjectType, "channel field ObjectType must survive rollback")
assert.Equal(t, "channel", channelField.TargetType, "channel field TargetType must survive rollback")
assert.Equal(t, channelTargetID, channelField.TargetID, "channel field TargetID must survive rollback")
assert.True(t, channelField.PermissionField.Valid, "channel field PermissionField must survive rollback")
assert.Equal(t, "sysadmin", channelField.PermissionField.String)
assert.True(t, channelField.PermissionValues.Valid)
assert.Equal(t, "member", channelField.PermissionValues.String)
assert.True(t, channelField.PermissionOptions.Valid)
assert.Equal(t, "sysadmin", channelField.PermissionOptions.String)
}
func TestMigration000185NoOpOnFreshDB(t *testing.T) {
logger := mlog.CreateTestLogger(t)
settings, err := makeSqlSettings(model.DatabaseDriverPostgres)
if err != nil {
t.Skip(err)
}
store, err := New(*settings, logger, nil)
require.NoError(t, err)
defer store.Close()
master := store.GetMaster()
upSQL := readMigrationSQL(t, "000176_migrate_cpa_to_access_control.up.sql")
downSQL := readMigrationSQL(t, "000176_migrate_cpa_to_access_control.down.sql")
// On a fresh database with no CPA group, both up and down should be
// safe no-ops (the UPDATE statements match zero rows).
_, err = master.ExecNoTimeout(upSQL)
assert.NoError(t, err, "up migration should be a safe no-op on fresh DB")
// Even with no CPA data, the view should be (re)created.
var viewExists bool
require.NoError(t, master.Get(&viewExists, "SELECT EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = 'attributeview')"))
assert.True(t, viewExists, "AttributeView should exist after up migration on fresh DB")
_, err = master.ExecNoTimeout(downSQL)
assert.NoError(t, err, "down migration should be a safe no-op on fresh DB")
}