保留存储过程与函数导出、权限检查、编码处理、共享存储对象逻辑及正式测试,同步两份任务进度文档。 已知阻塞:已有MySQL 5.7实库记录中,两种恢复入口均存在Latin-1例程注释乱码;已有8.0编码与权限专项通过,但不能代替最终双版本全量复测。本次仅重新运行例程单元测试及相关包静态检查,均通过。 这是未完成整改的保存检查点,不是发布版本,不表示P0-05完成或生产验收通过。未纳入运行数据、构建产物、原始实库日志或凭据。
257 lines
11 KiB
Go
257 lines
11 KiB
Go
package cli
|
||
|
||
import (
|
||
"database/sql"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/go-sql-driver/mysql"
|
||
|
||
"git.pcux.cn/xuans/Scert/backend/internal/database"
|
||
"git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest"
|
||
)
|
||
|
||
func TestMySQLRealNativeRoutines(t *testing.T) {
|
||
server := mysqltest.New(t)
|
||
source := server.Database(t)
|
||
parsed, err := mysql.ParseDSN(source.DSN)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
grantMySQLProgramAccess(t, server, parsed.User, parsed.DBName)
|
||
// 只调整独立实例专用账户,不能修改共享服务的函数信任开关。
|
||
account := mysqlStringLiteral(parsed.User) + "@'127.0.0.1'"
|
||
grant := "GRANT SELECT ON mysql.proc TO " + account
|
||
if server.IsMySQL8() {
|
||
grant = "GRANT SHOW_ROUTINE, SUPER ON *.* TO " + account
|
||
}
|
||
if _, err := server.Admin().ExecContext(t.Context(), grant); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
db, err := database.Open(t.Context(), source)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer db.Close()
|
||
connection, err := db.DB.Conn(t.Context())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer connection.Close()
|
||
for _, statement := range []string{
|
||
"SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION'",
|
||
"SET NAMES utf8mb4 COLLATE utf8mb4_bin",
|
||
"CREATE TABLE interop (id INT PRIMARY KEY, value INT NOT NULL, label VARCHAR(100)) ENGINE=InnoDB",
|
||
"INSERT INTO interop VALUES (1, 10, '原始值')",
|
||
"CREATE FUNCTION format_value(input_value DECIMAL(20,4)) RETURNS VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DETERMINISTIC NO SQL SQL SECURITY INVOKER COMMENT '函数说明' RETURN CONCAT('函数;', input_value)",
|
||
"CREATE PROCEDURE `change``value`(IN target_id INT, INOUT step_value INT, OUT result_label VARCHAR(100)) SQL SECURITY INVOKER COMMENT '过程说明' BEGIN UPDATE interop SET value = value + step_value WHERE id = target_id; SET step_value = step_value + 1; SET result_label = format_value(step_value); END",
|
||
"CREATE TRIGGER routine_trigger BEFORE INSERT ON interop FOR EACH ROW SET NEW.label = format_value(NEW.value)",
|
||
"ALTER DATABASE COLLATE latin1_swedish_ci",
|
||
"SET NAMES latin1 COLLATE latin1_bin",
|
||
"CREATE FUNCTION `legacy_caf" + string([]byte{0xe9}) + "`() RETURNS VARCHAR(100) DETERMINISTIC NO SQL COMMENT 'caf" + string([]byte{0xe9}) + ";;' RETURN 'caf" + string([]byte{0xe9}) + ";;'",
|
||
"SET NAMES utf8mb4 COLLATE utf8mb4_bin",
|
||
"ALTER DATABASE COLLATE utf8mb4_unicode_ci",
|
||
} {
|
||
if _, err := connection.ExecContext(t.Context(), statement); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
definition := readMySQLRoutineDefinitions(t, db.DB)
|
||
output := filepath.Join(server.Root(), "native-routines.sql")
|
||
if err := backupMySQLDatabase(t.Context(), source, output); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, direction := range []string{"scert_to_scert", "scert_to_official"} {
|
||
t.Run(direction, func(t *testing.T) {
|
||
target := server.Database(t)
|
||
targetDSN, err := mysql.ParseDSN(target.DSN)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
grantMySQLProgramAccess(t, server, parsed.User, targetDSN.DBName)
|
||
targetDSN.User = parsed.User
|
||
target.DSN = targetDSN.FormatDSN()
|
||
verify, err := database.Open(t.Context(), target)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer verify.Close()
|
||
// 恢复到已有同名例程的数据库,不能只验证空库创建。
|
||
if _, err := verify.DB.ExecContext(t.Context(), "CREATE FUNCTION format_value(input_value INT) RETURNS INT DETERMINISTIC NO SQL RETURN -1"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for attempt := 0; attempt < 2; attempt++ {
|
||
if direction == "scert_to_official" {
|
||
server.Import(t, target, output)
|
||
} else if err := restoreMySQL(t.Context(), target, output); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if restored := readMySQLRoutineDefinitions(t, verify.DB); restored != definition {
|
||
t.Fatalf("例程定义、参数、权限上下文或字符集不一致:\n源:%s\n恢复:%s", definition, restored)
|
||
}
|
||
checkMySQLInteropValue(t, target, "SELECT CONCAT(value, '|', label) FROM interop WHERE id = 1", "10|原始值")
|
||
checkMySQLInteropValue(t, target, "SELECT format_value(7)", "函数;7.0000")
|
||
checkMySQLInteropValue(t, target, "SELECT `legacy_café`()", "café;;")
|
||
checkMySQLInteropValue(t, target, "SELECT @@collation_database", "utf8mb4_unicode_ci")
|
||
conn, err := verify.DB.Conn(t.Context())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, statement := range []string{"SET @step = 5", "CALL `change``value`(1, @step, @label)", "INSERT INTO interop VALUES (2, 4, '新行')"} {
|
||
if _, err := conn.ExecContext(t.Context(), statement); err != nil {
|
||
_ = conn.Close()
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
var actual string
|
||
err = conn.QueryRowContext(t.Context(), "SELECT CONCAT(@step, '|', @label)").Scan(&actual)
|
||
_ = conn.Close()
|
||
if err != nil || actual != "6|函数;6.0000" {
|
||
t.Fatalf("过程输入输出参数错误:%q,%v", actual, err)
|
||
}
|
||
checkMySQLInteropValue(t, target, "SELECT CAST(value AS CHAR) FROM interop WHERE id = 1", "15")
|
||
checkMySQLInteropValue(t, target, "SELECT label FROM interop WHERE id = 2", "函数;4.0000")
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestMySQLRealRoutineVisibility(t *testing.T) {
|
||
server := mysqltest.New(t)
|
||
scenarios := []string{"visible", "hidden", "empty_schema_without_permission", "global_select", "execute_only"}
|
||
if server.IsMySQL8() {
|
||
scenarios = append(scenarios, "active_role", "nested_role", "inactive_role", "partial_revoke", "partial_revoke_with_show")
|
||
} else {
|
||
scenarios = append(scenarios, "column_only")
|
||
}
|
||
for _, scenario := range scenarios {
|
||
t.Run(scenario, func(t *testing.T) {
|
||
cfg := server.Database(t)
|
||
parsed, err := mysql.ParseDSN(cfg.DSN)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
account := mysqlStringLiteral(parsed.User) + "@'127.0.0.1'"
|
||
permission := "SELECT ON mysql.proc"
|
||
if server.IsMySQL8() {
|
||
permission = "SHOW_ROUTINE ON *.*"
|
||
}
|
||
if _, err := server.Admin().ExecContext(t.Context(), "GRANT "+permission+" TO "+account); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if scenario != "empty_schema_without_permission" {
|
||
statement := "CREATE PROCEDURE " + quoteMySQLIdentifier(parsed.DBName) + ".hidden_routine() SELECT 123"
|
||
if _, err := server.Admin().ExecContext(t.Context(), statement); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
allowed := scenario == "visible" || scenario == "global_select" || scenario == "active_role" || scenario == "nested_role" || scenario == "partial_revoke_with_show"
|
||
if scenario != "visible" && scenario != "partial_revoke_with_show" {
|
||
if _, err := server.Admin().ExecContext(t.Context(), "REVOKE "+permission+" FROM "+account); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
var statements []string
|
||
switch scenario {
|
||
case "global_select":
|
||
statements = append(statements, "GRANT SELECT ON *.* TO "+account)
|
||
case "execute_only":
|
||
statements = append(statements, "GRANT EXECUTE ON "+quoteMySQLIdentifier(parsed.DBName)+".* TO "+account)
|
||
case "column_only":
|
||
statements = append(statements, "GRANT SELECT (db, name, type) ON mysql.proc TO "+account)
|
||
case "active_role", "nested_role", "inactive_role":
|
||
role := quoteMySQLIdentifier("routine_"+scenario) + "@'%'"
|
||
statements = append(statements, "CREATE ROLE "+role, "GRANT SHOW_ROUTINE ON *.* TO "+role, "GRANT "+role+" TO "+account)
|
||
if scenario == "active_role" {
|
||
statements = append(statements, "SET DEFAULT ROLE "+role+" TO "+account)
|
||
}
|
||
if scenario == "nested_role" {
|
||
parent := "'routine_parent'@'%'"
|
||
statements = append(statements, "REVOKE "+role+" FROM "+account, "CREATE ROLE "+parent, "GRANT "+role+" TO "+parent, "GRANT "+parent+" TO "+account, "SET DEFAULT ROLE "+parent+" TO "+account)
|
||
}
|
||
case "partial_revoke", "partial_revoke_with_show":
|
||
// 先移除夹具的库级授权,否则一次 REVOKE 只移除该授权而不撤销全局权限。
|
||
statements = append(statements, "REVOKE SELECT ON "+quoteMySQLIdentifier(parsed.DBName)+".* FROM "+account,
|
||
"SET GLOBAL partial_revokes = ON", "GRANT SELECT ON *.* TO "+account, "REVOKE SELECT ON "+quoteMySQLIdentifier(parsed.DBName)+".* FROM "+account)
|
||
}
|
||
for _, statement := range statements {
|
||
if _, err := server.Admin().ExecContext(t.Context(), statement); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
if scenario == "hidden" || scenario == "empty_schema_without_permission" || scenario == "inactive_role" {
|
||
checkMySQLInteropValue(t, cfg, "SELECT CAST(COUNT(*) AS CHAR) FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = DATABASE()", "0")
|
||
}
|
||
if allowed || scenario == "execute_only" {
|
||
checkMySQLInteropValue(t, cfg, "SELECT CAST(COUNT(*) AS CHAR) FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = DATABASE()", "1")
|
||
}
|
||
if scenario == "execute_only" {
|
||
checkMySQLInteropValue(t, cfg, "SELECT CAST(ROUTINE_DEFINITION IS NULL AS CHAR) FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = DATABASE()", "1")
|
||
}
|
||
output := filepath.Join(server.Root(), "routines-"+scenario+".sql")
|
||
if err := os.WriteFile(output, []byte("existing backup"), 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
err = backupMySQLDatabase(t.Context(), cfg, output)
|
||
if allowed {
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
data, err := os.ReadFile(output)
|
||
if err != nil || !strings.Contains(string(data), "hidden_routine") {
|
||
t.Fatal("可见例程未完整导出")
|
||
}
|
||
} else {
|
||
if err == nil || !strings.Contains(err.Error(), "例程") {
|
||
t.Fatalf("缺少完整例程可见权限必须拒绝:%v", err)
|
||
}
|
||
data, readErr := os.ReadFile(output)
|
||
if readErr != nil || string(data) != "existing backup" {
|
||
t.Fatal("权限不足破坏了原有备份")
|
||
}
|
||
if _, err := os.Stat(output + ".tmp"); !os.IsNotExist(err) {
|
||
t.Fatal("权限不足遗留临时文件")
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func readMySQLRoutineDefinitions(t *testing.T, db *sql.DB) string {
|
||
t.Helper()
|
||
queries := []string{
|
||
`SELECT JSON_ARRAY(ROUTINE_NAME, ROUTINE_TYPE, DTD_IDENTIFIER, ROUTINE_BODY, ROUTINE_DEFINITION,
|
||
IS_DETERMINISTIC, SQL_DATA_ACCESS, SECURITY_TYPE, SQL_MODE, ROUTINE_COMMENT, DEFINER,
|
||
CHARACTER_SET_CLIENT, COLLATION_CONNECTION, DATABASE_COLLATION)
|
||
FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = DATABASE() ORDER BY ROUTINE_TYPE, ROUTINE_NAME`,
|
||
`SELECT JSON_ARRAY(SPECIFIC_NAME, ORDINAL_POSITION, PARAMETER_MODE, PARAMETER_NAME,
|
||
DTD_IDENTIFIER, CHARACTER_SET_NAME, COLLATION_NAME, ROUTINE_TYPE)
|
||
FROM information_schema.PARAMETERS WHERE SPECIFIC_SCHEMA = DATABASE() ORDER BY SPECIFIC_NAME, ORDINAL_POSITION`,
|
||
}
|
||
var definitions []string
|
||
for _, query := range queries {
|
||
rows, err := db.QueryContext(t.Context(), query)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for rows.Next() {
|
||
var definition string
|
||
if err := rows.Scan(&definition); err != nil {
|
||
_ = rows.Close()
|
||
t.Fatal(err)
|
||
}
|
||
definitions = append(definitions, definition)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
_ = rows.Close()
|
||
t.Fatal(err)
|
||
}
|
||
if err := rows.Close(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
return strings.Join(definitions, "\n")
|
||
}
|