"""隔离实机验收的 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', '测试数据库账号锁定未生效'