Smail/test/tools/runtime_sql.py
smail b71fbc49fe
Some checks are pending
Security Checks / security (push) Waiting to run
完善单数据库识别并对齐统一配置迁移验收
2026-09-22 10:25:53 +08:00

134 lines
6.5 KiB
Python

"""隔离实机验收的 SQL 数据库适配;不删除数据库,不记录连接凭据。"""
import base64
import json
import os
import re
import secrets
import subprocess
from database_inventory import sqlite_inventory
def command(args, sql, env=None):
result = subprocess.run(args, input=sql, text=True, capture_output=True,
timeout=30, env=env, encoding='utf-8')
if result.returncode:
# 驱动错误可能包含连接参数,不能直接传播到验收日志。
raise RuntimeError('隔离测试数据库操作失败,请在受保护环境核查')
return result.stdout.strip()
class SQLDatabase:
def __init__(self, settings):
assert set(settings) == {'kind', 'name', 'user', 'password'}
assert settings['kind'] in ('mysql', 'postgres')
assert re.fullmatch(r'smail_runtime_test_[0-9a-f]{12}', settings['name'])
assert re.fullmatch(r'smail_rt_[0-9a-f]{12}', settings['user'])
assert re.fullmatch(r'[A-Za-z0-9_-]{32,64}', settings['password'])
self.settings = settings.copy()
self.kind = settings['kind']
self.port = 3306 if self.kind == 'mysql' else 5432
def setup_payload(self):
return {'step': 'database', 'action': 'set', 'db_type': self.kind,
'db_mode': 'fields', 'db_host': '127.0.0.1', 'db_port': str(self.port),
'db_name': self.settings['name'], 'db_user': self.settings['user'],
'db_password': self.settings['password']}
def query(self, sql):
env = os.environ.copy()
if self.kind == 'mysql':
env['MYSQL_PWD'] = self.settings['password']
args = ['mysql', '--protocol=tcp', '--host=127.0.0.1', '--port=3306',
'--user=' + self.settings['user'], '--database=' + self.settings['name'],
'--default-character-set=utf8mb4', '--batch', '--raw', '--skip-column-names']
else:
env['PGPASSWORD'] = self.settings['password']
env['PGCLIENTENCODING'] = 'UTF8'
args = ['psql', '-X', '-v', 'ON_ERROR_STOP=1', '-At', '--host=127.0.0.1',
'--port=5432', '--username=' + self.settings['user'],
'--dbname=' + self.settings['name']]
return command(args, sql, env)
def assert_empty_users(self):
table = '`user`' if self.kind == 'mysql' else '"user"'
assert self.query('SELECT COUNT(*) FROM ' + table) == '0'
def verify_message(self, message_id, account, body, attachment):
assert re.fullmatch(r'[a-zA-Z0-9@._-]+', message_id)
constructor = 'JSON_OBJECT' if self.kind == 'mysql' else 'json_build_object'
content = self.query("SELECT " + constructor + "('id',id,'text',text,'attachments',attachments)"
" FROM email WHERE msg_id='" + message_id + "'")
rows = content.splitlines()
assert len(rows) == 1
record = json.loads(rows[0])
assert body in record['text']
attachments = record['attachments']
if isinstance(attachments, str):
attachments = json.loads(attachments)
assert len(attachments) == 1 and attachments[0]['Filename'] == '验收附件.txt'
assert base64.b64decode(attachments[0]['Content']) == attachment
table = '`user`' if self.kind == 'mysql' else '"user"'
assert isinstance(record['id'], int) and record['id'] > 0
owners = self.query('SELECT u.account FROM user_email e JOIN ' + table +
' u ON u.id=e.user_id WHERE e.email_id=' + str(record['id']))
assert owners.splitlines() == [account]
def integrity(self, root):
# 配置历史和业务表必须位于所选 SQL 库,运行目录不能悄悄创建 SQLite。
sqlite_inventory(root, [])
count = int(self.query('SELECT COUNT(*) FROM config_records'))
assert count > 0
return {'type': self.kind, 'database': self.settings['name'],
'config_records': count, 'sqlite_files': 0}
class SQLFixture(SQLDatabase):
def __init__(self, kind):
token = secrets.token_hex(6)
super().__init__({'kind': kind, 'name': 'smail_runtime_test_' + token,
'user': 'smail_rt_' + token, 'password': secrets.token_urlsafe(30)})
self.account_created = False
def admin(self, sql, database=None):
if self.kind == 'mysql':
args = ['mysql', '--protocol=socket', '--batch', '--skip-column-names']
else:
args = ['runuser', '-u', 'postgres', '--', 'psql', '-X', '-v', 'ON_ERROR_STOP=1', '-At']
if database:
assert database == self.settings['name']
args += ['--dbname=' + database]
return command(args, sql)
def create(self):
name, user, password = (self.settings[key] for key in ('name', 'user', 'password'))
if self.kind == 'mysql':
self.admin("CREATE USER '" + user + "'@'localhost' IDENTIFIED BY '" + password +
"' WITH MAX_USER_CONNECTIONS 24;")
self.account_created = True
self.admin('CREATE DATABASE `' + name + '` CHARACTER SET utf8mb4;')
# 转义授权模式中的下划线,只授权本轮唯一数据库。
self.admin('GRANT ALL ON `' + name.replace('_', r'\_') + "`.* TO '" + user + "'@'localhost';")
count = self.admin("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='" + name + "';")
else:
self.admin('CREATE ROLE ' + user + " LOGIN CONNECTION LIMIT 24 PASSWORD '" + password + "';")
self.account_created = True
self.admin('CREATE DATABASE ' + name + ' OWNER ' + user + ';')
self.admin('GRANT ALL ON SCHEMA public TO ' + user + ';', name)
count = self.admin("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public';", name)
assert count == '0', '首次安装必须使用空数据库'
def lock(self):
if not self.account_created:
return
user = self.settings['user']
if self.kind == 'mysql':
self.admin("ALTER USER '" + user + "'@'localhost' ACCOUNT LOCK;")
assert self.admin("SELECT account_locked FROM mysql.user WHERE User='" + user +
"' AND Host='localhost';") == 'Y', '测试数据库账号锁定未生效'
else:
self.admin('ALTER ROLE ' + user + ' NOLOGIN;')
assert self.admin("SELECT rolcanlogin FROM pg_roles WHERE rolname='" + user +
"';") == 'f', '测试数据库账号锁定未生效'