<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #46390 Migration to add a non nullable team_id column. I chose this approach so that server code doesn't have to deal with a potentially null team id for categories. It first adds team_id=0 to all the existing categories, then duplicates that for every fleet so that default categories can be edited and deleted by admins. It also renames the default categories to include the emojis in their name, which required updating some test expectations, and also mapping default names to the new ones for Fleet maintained apps. If we don't do that, FMA's manifests would have to all be updated right after 4.87 releases and every user would have to migrate immediately. This would also break existing gitops files if the names aren't mapped. The alternative would be to keep the names unchanged, and add custom logic in various places to insert the emojis in the backend and frontend. # Checklist for submitter If some of the following don't apply, delete the relevant line. ## Testing - [x] Added/updated automated tests - [ ] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [x] QA'd all new/changed functionality manually ## Database migrations - [x] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [x] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [x] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`).
244 lines
6.9 KiB
Go
244 lines
6.9 KiB
Go
package testing_utils
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
|
|
common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
|
|
"github.com/jmoiron/sqlx"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
const (
|
|
TestUsername = "root"
|
|
TestPassword = "toor"
|
|
TestReplicaDatabaseSuffix = "_replica"
|
|
)
|
|
|
|
var TestReplicaAddress = getTestReplicaAddress()
|
|
|
|
// getTestReplicaAddress returns the MySQL replica test server address from environment variable
|
|
// FLEET_MYSQL_REPLICA_TEST_PORT or defaults to localhost:3310
|
|
func getTestReplicaAddress() string {
|
|
if port := os.Getenv("FLEET_MYSQL_REPLICA_TEST_PORT"); port != "" {
|
|
return "localhost:" + port
|
|
}
|
|
return "localhost:3310"
|
|
}
|
|
|
|
var TestAddress = getTestAddress()
|
|
|
|
// getTestAddress returns the MySQL test server address from environment variable
|
|
// FLEET_MYSQL_TEST_PORT or defaults to localhost:3307
|
|
func getTestAddress() string {
|
|
if port := os.Getenv("FLEET_MYSQL_TEST_PORT"); port != "" {
|
|
return "localhost:" + port
|
|
}
|
|
return "localhost:3307"
|
|
}
|
|
|
|
// TruncateTables truncates the specified tables, in order, using ds.writer.
|
|
// Note that the order is typically not important because FK checks are
|
|
// disabled while truncating. If no table is provided, all tables (except
|
|
// those that are seeded by the SQL schema file) are truncated.
|
|
func TruncateTables(t testing.TB, db *sqlx.DB, logger *slog.Logger, nonEmptyTables map[string]bool, tables ...string) {
|
|
// By setting DISABLE_TRUNCATE_TABLES a developer can troubleshoot tests
|
|
// by inspecting mysql tables.
|
|
if os.Getenv("DISABLE_TRUNCATE_TABLES") != "" {
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, common_mysql.WithTxx(ctx, db, func(tx sqlx.ExtContext) error {
|
|
var skipSeeded bool
|
|
|
|
if len(tables) == 0 {
|
|
skipSeeded = true
|
|
sql := `
|
|
SELECT
|
|
table_name
|
|
FROM
|
|
information_schema.tables
|
|
WHERE
|
|
table_schema = database() AND
|
|
table_type = 'BASE TABLE'
|
|
`
|
|
if err := sqlx.SelectContext(ctx, tx, &tables, sql); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if _, err := tx.ExecContext(ctx, `SET FOREIGN_KEY_CHECKS=0`); err != nil {
|
|
return err
|
|
}
|
|
for _, tbl := range tables {
|
|
if nonEmptyTables[tbl] {
|
|
if skipSeeded {
|
|
continue
|
|
}
|
|
return fmt.Errorf("cannot truncate table %s, it contains seed data from schema.sql", tbl)
|
|
}
|
|
if _, err := tx.ExecContext(ctx, "TRUNCATE TABLE "+tbl); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `SET FOREIGN_KEY_CHECKS=1`); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}, logger))
|
|
}
|
|
|
|
// DatastoreTestOptions configures how the test datastore is created
|
|
// by CreateMySQLDSWithOptions.
|
|
type DatastoreTestOptions struct {
|
|
// DummyReplica indicates that a read replica test database should be created.
|
|
DummyReplica bool
|
|
|
|
// RunReplication is the function to call to execute the replication of all
|
|
// missing changes from the primary to the replica. The function is created
|
|
// and set automatically by CreateMySQLDSWithOptions. The test is in full
|
|
// control of when the replication is executed. Only applies to DummyReplica.
|
|
// Note that not all changes to data show up in the information_schema
|
|
// update_time timestamp, so to work around that limitation, explicit table
|
|
// names can be provided to force their replication.
|
|
RunReplication func(forceTables ...string)
|
|
|
|
// RealReplica indicates that the replica should be a real DB replica, with a dedicated connection.
|
|
RealReplica bool
|
|
|
|
UniqueTestName string
|
|
}
|
|
|
|
// LoadDefaultSchema loads the default database schema for testing.
|
|
func LoadDefaultSchema(t testing.TB, testName string, opts *DatastoreTestOptions) {
|
|
_, thisFile, _, _ := runtime.Caller(0)
|
|
schemaPath := filepath.Join(filepath.Dir(thisFile), "../../../datastore/mysql/schema.sql")
|
|
LoadSchema(t, testName, opts, schemaPath)
|
|
}
|
|
|
|
// LoadSchema loads a database schema from the specified path for testing.
|
|
func LoadSchema(t testing.TB, testName string, opts *DatastoreTestOptions, schemaPath string) {
|
|
schema, err := os.ReadFile(schemaPath)
|
|
if err != nil {
|
|
t.Error(err)
|
|
t.FailNow()
|
|
}
|
|
|
|
// execute the schema for the test db, and once more for the replica db if
|
|
// that option is set.
|
|
dbs := []string{testName}
|
|
if opts.DummyReplica {
|
|
dbs = append(dbs, testName+TestReplicaDatabaseSuffix)
|
|
}
|
|
for _, dbName := range dbs {
|
|
// Load schema from dumpfile
|
|
sqlCommands := fmt.Sprintf(
|
|
"DROP DATABASE IF EXISTS %s; CREATE DATABASE %s; USE %s; SET FOREIGN_KEY_CHECKS=0; %s;",
|
|
dbName, dbName, dbName, schema,
|
|
)
|
|
|
|
cmd := exec.Command(
|
|
"docker", "compose", "exec", "-T", "mysql_test",
|
|
// Command run inside container
|
|
"mysql",
|
|
"--default-character-set=utf8mb4",
|
|
"-u"+TestUsername, "-p"+TestPassword,
|
|
)
|
|
cmd.Stdin = strings.NewReader(sqlCommands)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Error(err)
|
|
t.Error(string(out))
|
|
t.FailNow()
|
|
}
|
|
}
|
|
if opts.RealReplica {
|
|
// Load schema from dumpfile
|
|
sqlCommands := fmt.Sprintf(
|
|
"DROP DATABASE IF EXISTS %s; CREATE DATABASE %s; USE %s; SET FOREIGN_KEY_CHECKS=0; %s;",
|
|
testName, testName, testName, schema,
|
|
)
|
|
|
|
cmd := exec.Command(
|
|
"docker", "compose", "exec", "-T", "mysql_replica_test",
|
|
// Command run inside container
|
|
"mysql",
|
|
"--default-character-set=utf8mb4",
|
|
"-u"+TestUsername, "-p"+TestPassword,
|
|
)
|
|
cmd.Stdin = strings.NewReader(sqlCommands)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Error(err)
|
|
t.Error(string(out))
|
|
t.FailNow()
|
|
}
|
|
}
|
|
}
|
|
|
|
func MysqlTestConfig(testName string) *common_mysql.MysqlConfig {
|
|
return &common_mysql.MysqlConfig{
|
|
Username: TestUsername,
|
|
Password: TestPassword,
|
|
Database: testName,
|
|
Address: TestAddress,
|
|
MaxIdleConns: 50,
|
|
MaxOpenConns: 50,
|
|
ConnMaxLifetime: 0,
|
|
}
|
|
}
|
|
|
|
func ProcessOptions(t testing.TB, opts *DatastoreTestOptions) (string, *DatastoreTestOptions) {
|
|
if _, ok := os.LookupEnv("MYSQL_TEST"); !ok {
|
|
t.Skip("MySQL tests are disabled")
|
|
}
|
|
|
|
if opts == nil {
|
|
// so it is never nil in internal helper functions
|
|
opts = new(DatastoreTestOptions)
|
|
}
|
|
|
|
if tt, ok := t.(*testing.T); ok && !opts.RealReplica {
|
|
tt.Parallel()
|
|
}
|
|
|
|
if opts.RealReplica {
|
|
if _, ok := os.LookupEnv("MYSQL_REPLICA_TEST"); !ok {
|
|
t.Skip("MySQL replica tests are disabled. Set env var MYSQL_REPLICA_TEST=1 to enable.")
|
|
}
|
|
}
|
|
|
|
var cleanTestName string
|
|
if opts.UniqueTestName != "" {
|
|
cleanTestName = opts.UniqueTestName
|
|
} else {
|
|
const numberOfStackFramesFromTest = 3
|
|
pc, _, _, ok := runtime.Caller(numberOfStackFramesFromTest)
|
|
details := runtime.FuncForPC(pc)
|
|
if !ok || details == nil {
|
|
t.FailNow()
|
|
}
|
|
|
|
cleanTestName = strings.ReplaceAll(
|
|
strings.TrimPrefix(details.Name(), "github.com/fleetdm/fleet/v4/"), "/", "_",
|
|
)
|
|
}
|
|
|
|
cleanTestName = strings.ReplaceAll(cleanTestName, ".", "_")
|
|
if len(cleanTestName) > 60 {
|
|
// the later parts are more unique than the start, with the package names,
|
|
// so trim from the start.
|
|
cleanTestName = cleanTestName[len(cleanTestName)-60:]
|
|
}
|
|
return cleanTestName, opts
|
|
}
|