315 lines
18 KiB
C++
315 lines
18 KiB
C++
/* Реальные Qt-диалоги, ядро в отдельном uasync-потоке и SQLite: commit, отказы и lifetime результата. */
|
|
#include "../src/renamedialog.h"
|
|
#include "../src/memberpropsdialog.h"
|
|
#include "../transport/member_edit.h"
|
|
#include "../transport/gui_bridge.h"
|
|
#include "../db/db_manager.h"
|
|
#include <QtTest>
|
|
#include <QLabel>
|
|
#include <QTemporaryDir>
|
|
#include <future>
|
|
#include <thread>
|
|
#include <condition_variable>
|
|
#include <memory>
|
|
#include <cstring>
|
|
extern "C" {
|
|
#include "utun_instance.h"
|
|
#include "chat/chat_core.h"
|
|
#include "debug_config.h"
|
|
#include "mem.h"
|
|
}
|
|
|
|
#ifdef UTUN_TEST_MEMBER_ALLOC_FAILURES
|
|
static int failMalloc, failCalloc;
|
|
extern "C" void* __real_u_malloc_impl(uint32_t, const char*);
|
|
extern "C" void* __real_u_calloc_impl(uint32_t, uint32_t, const char*);
|
|
extern "C" void* __wrap_u_malloc_impl(uint32_t bytes, const char* where) {
|
|
if (failMalloc && strstr(where, "gui_bridge_impl.cpp") && !--failMalloc) {
|
|
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "[INJECT] member snapshot allocation failure"); return nullptr;
|
|
}
|
|
return __real_u_malloc_impl(bytes, where);
|
|
}
|
|
extern "C" void* __wrap_u_calloc_impl(uint32_t count, uint32_t bytes, const char* where) {
|
|
if (failCalloc && strstr(where, "gui_bridge_impl.cpp") && !--failCalloc) {
|
|
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "[INJECT] member queue allocation failure"); return nullptr;
|
|
}
|
|
return __real_u_calloc_impl(count, bytes, where);
|
|
}
|
|
#endif
|
|
|
|
struct MemberCore {
|
|
QTemporaryDir directory;
|
|
DbManager db;
|
|
UTUN_INSTANCE* instance = nullptr;
|
|
UASYNC* ua = nullptr;
|
|
QString channel;
|
|
quint64 node = 0;
|
|
uint8_t flags = 0;
|
|
std::thread worker;
|
|
std::mutex mutex;
|
|
std::condition_variable wake;
|
|
bool paused = true, active = false, stopping = false;
|
|
|
|
void stop() {
|
|
{ std::lock_guard<std::mutex> lock(mutex); stopping = true; wake.notify_all(); }
|
|
if (worker.joinable()) worker.join();
|
|
db.setDb(nullptr);
|
|
}
|
|
~MemberCore() { stop(); }
|
|
bool start() {
|
|
std::promise<bool> ready; auto result = ready.get_future();
|
|
worker = std::thread([this, &ready] {
|
|
ua = uasync_create();
|
|
instance = ua ? utun_instance_create_from_str(ua,
|
|
"[global]\nmy_private_key=704f2e012c8fa8768130cb0f988a997dccb628372bc5ceccacc78dcbfec5916f\n"
|
|
"my_public_key=b3193173def895bd0fcea6f86af077c7d77216f10395275f627ac18242ec0f01\n"
|
|
"[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\nmember_files_enabled=0\n"
|
|
"local_invites_enabled=0\nlocal_broadcasts_enabled=0\n[ntp]\nenabled=no\n") : nullptr;
|
|
bool ok = instance && !utun_core_start(instance) && !chat_service_start(instance);
|
|
uint64_t id = ok ? chat_core_create_channel_auto(instance, "Members") : 0;
|
|
ok = ok && id;
|
|
if (ok) {
|
|
channel = QString::number(id); node = instance->node_id;
|
|
const QByteArray sql = QString("UPDATE peers_%1 SET local_nick='Old nickname' WHERE node_id=%2")
|
|
.arg(channel).arg(static_cast<qint64>(node)).toUtf8();
|
|
ok = sqlite3_exec(instance->topo_sqlite_db, sql.constData(), nullptr, nullptr, nullptr) == SQLITE_OK;
|
|
uint8_t wire[CHAT_MEMBER_DISPLAY_SIZE]; chat_member_display member{};
|
|
ok = ok && !chat_core_get_single_member(instance, qPrintable(channel), node, wire) &&
|
|
!chat_core_deserialize_member(wire, &member);
|
|
flags = member.flags;
|
|
gui_bridge_set_uasync(ua); gui_bridge_set_inst(instance);
|
|
}
|
|
ready.set_value(ok);
|
|
if (ok) {
|
|
std::unique_lock<std::mutex> lock(mutex);
|
|
while (!stopping) {
|
|
wake.wait(lock, [this] { return !paused || stopping; });
|
|
if (stopping) break;
|
|
active = true; lock.unlock(); uasync_poll(ua, 10); lock.lock(); active = false; wake.notify_all();
|
|
}
|
|
}
|
|
gui_bridge_set_inst(nullptr); gui_bridge_set_uasync(nullptr);
|
|
if (ua) uasync_mark_stopped(ua);
|
|
if (instance) utun_instance_destroy(instance);
|
|
if (ua) uasync_destroy(ua, 0);
|
|
});
|
|
if (!result.get()) return false;
|
|
db.setDb(instance->topo_sqlite_db); db.setMyNodeId(node); return true;
|
|
}
|
|
void resume() { std::lock_guard<std::mutex> lock(mutex); paused = false; wake.notify_all(); }
|
|
void pause() { std::unique_lock<std::mutex> lock(mutex); paused = true; wake.wait(lock, [this] { return !active; }); }
|
|
int run(std::function<int()> function) {
|
|
auto task = std::make_unique<std::packaged_task<int()>>(std::move(function)); auto result = task->get_future();
|
|
gui_bridge_post_uasync_fn([](void* arg) {
|
|
std::unique_ptr<std::packaged_task<int()>> task(static_cast<std::packaged_task<int()>*>(arg)); (*task)();
|
|
}, task.release());
|
|
resume();
|
|
if (result.wait_for(std::chrono::seconds(5)) != std::future_status::ready) qFatal("Member test core command timed out");
|
|
int value = result.get(); pause(); return value;
|
|
}
|
|
QByteArray snapshot() {
|
|
sqlite3_stmt* st = nullptr;
|
|
QByteArray sql = QString("SELECT coalesce(local_nick,''),coalesce(adm_tags,''),hex(adm_tags_sig) FROM peers_%1 WHERE node_id=%2")
|
|
.arg(channel).arg(static_cast<qint64>(node)).toUtf8();
|
|
if (sqlite3_prepare_v2(db.m_db, sql.constData(), -1, &st, nullptr) != SQLITE_OK) qFatal("Snapshot prepare failed");
|
|
QByteArray result;
|
|
if (sqlite3_step(st) == SQLITE_ROW) {
|
|
for (int i = 0; i < 3; ++i) { result += DbManager::colText(st, i).toUtf8(); result += '\n'; }
|
|
}
|
|
if (sqlite3_finalize(st) != SQLITE_OK) qFatal("Snapshot finalize failed");
|
|
return result;
|
|
}
|
|
};
|
|
|
|
static std::unique_ptr<QDialog> makeDialog(MemberCore& core, bool properties) {
|
|
if (properties) return std::make_unique<MemberPropsDialog>(&core.db, core.channel, core.node, "Member", core.flags);
|
|
return std::make_unique<RenameDialog>(&core.db, core.channel, core.node, "Member");
|
|
}
|
|
static QLabel* errorLabel(QDialog* dialog) { return dialog->findChild<QLabel*>("memberSaveError"); }
|
|
static bool save(QDialog* dialog) { return QMetaObject::invokeMethod(dialog, "onSave", Qt::DirectConnection); }
|
|
|
|
class MemberDialogTest : public QObject {
|
|
Q_OBJECT
|
|
size_t allocationBaseline = 0;
|
|
private slots:
|
|
void initTestCase() {
|
|
debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN);
|
|
debug_set_category_level(DEBUG_CATEGORY_CHAT_SYNC, DEBUG_LEVEL_DEBUG); gui_bridge_init();
|
|
}
|
|
void init() { allocationBaseline = u_get_allocated_count(); }
|
|
void cleanup() { QCoreApplication::processEvents(); QCOMPARE(u_get_allocated_count(), allocationBaseline); }
|
|
void saves_data() {
|
|
QTest::addColumn<bool>("properties"); QTest::addColumn<QString>("failure");
|
|
for (bool properties : {false, true}) for (const char* failure : {"none", "prepare", "step", "commit", "missing"})
|
|
QTest::newRow(qPrintable(QString("%1-%2").arg(properties ? "properties" : "rename", failure))) << properties << QString(failure);
|
|
}
|
|
void saves() {
|
|
QFETCH(bool, properties); QFETCH(QString, failure);
|
|
MemberCore core; QVERIFY(core.start()); auto dialog = makeDialog(core, properties); auto before = core.snapshot();
|
|
struct Probe { bool deny; int updates = 0; bool gui = false, memberTransaction = false; int commitRefusals = 0; } probe{failure == "prepare"};
|
|
QCOMPARE(core.run([&] {
|
|
if (failure == "step") {
|
|
QByteArray sql = QString("CREATE TRIGGER refuse_nick BEFORE UPDATE OF local_nick ON peers_%1 "
|
|
"BEGIN SELECT RAISE(ABORT,'injected nickname failure'); END").arg(core.channel).toUtf8();
|
|
if (sqlite3_exec(core.db.m_db, sql.constData(), nullptr, nullptr, nullptr) != SQLITE_OK) return -1;
|
|
}
|
|
if (failure == "missing") {
|
|
QByteArray sql = QString("DELETE FROM peers_%1").arg(core.channel).toUtf8();
|
|
if (sqlite3_exec(core.db.m_db, sql.constData(), nullptr, nullptr, nullptr) != SQLITE_OK) return -1;
|
|
}
|
|
if (failure == "commit") {
|
|
sqlite3_trace_v2(core.db.m_db, SQLITE_TRACE_STMT, [](unsigned, void* arg, void* statement, void*) {
|
|
auto& probe = *static_cast<Probe*>(arg); const char* sql = sqlite3_sql(static_cast<sqlite3_stmt*>(statement));
|
|
if (sql && !strncmp(sql, "BEGIN", 5)) probe.memberTransaction =
|
|
!strcmp(sql, "BEGIN IMMEDIATE /* tags */") || !strcmp(sql, "BEGIN IMMEDIATE /* nickname */");
|
|
return 0;
|
|
}, &probe);
|
|
sqlite3_commit_hook(core.db.m_db, [](void* arg) {
|
|
auto& probe = *static_cast<Probe*>(arg);
|
|
if (!probe.memberTransaction) return 0;
|
|
probe.memberTransaction = false; ++probe.commitRefusals;
|
|
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "[INJECT] member commit refused"); return 1;
|
|
}, &probe);
|
|
}
|
|
return sqlite3_set_authorizer(core.db.m_db, [](void* arg, int action, const char*, const char* column, const char*, const char*) {
|
|
auto& probe = *static_cast<Probe*>(arg);
|
|
if (action != SQLITE_UPDATE || !column || strcmp(column, "local_nick")) return SQLITE_OK;
|
|
++probe.updates; probe.gui |= QThread::currentThread() == qApp->thread();
|
|
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "[PROBE] nickname UPDATE gui_thread=%d deny=%d", probe.gui, probe.deny);
|
|
return probe.deny ? SQLITE_DENY : SQLITE_OK;
|
|
}, &probe);
|
|
}), SQLITE_OK);
|
|
if (failure == "missing") before = core.snapshot();
|
|
dialog->findChild<QLineEdit*>()->setText(QString::fromUtf8(" Новый ник "));
|
|
QSignalSpy accepted(dialog.get(), &QDialog::accepted);
|
|
QVERIFY(save(dialog.get())); QVERIFY(save(dialog.get())); QCOMPARE(accepted.count(), 0);
|
|
QCOMPARE(core.snapshot(), before); // Ядро приостановлено: GUI ничего не записал.
|
|
core.resume();
|
|
if (failure == "none") QTRY_COMPARE_WITH_TIMEOUT(accepted.count(), 1, 3000);
|
|
else QTRY_VERIFY_WITH_TIMEOUT(!errorLabel(dialog.get())->text().isEmpty(), 3000);
|
|
core.pause(); QVERIFY(!probe.gui);
|
|
QCOMPARE(probe.updates, failure == "missing" && properties ? 0 : 1);
|
|
if (failure == "commit") QCOMPARE(probe.commitRefusals, 1);
|
|
if (failure == "none") QVERIFY(core.snapshot().startsWith(QString::fromUtf8("Новый ник\n").toUtf8()));
|
|
else {
|
|
QCOMPARE(accepted.count(), 0); QCOMPARE(core.snapshot(), before);
|
|
QVERIFY(dialog->findChild<QLineEdit*>()->text().contains(QString::fromUtf8("Новый ник")));
|
|
QVERIFY(sqlite3_get_autocommit(core.db.m_db));
|
|
}
|
|
QVERIFY(sqlite3_next_stmt(core.db.m_db, nullptr) == nullptr);
|
|
QCOMPARE(core.run([&] {
|
|
sqlite3_set_authorizer(core.db.m_db, nullptr, nullptr); sqlite3_commit_hook(core.db.m_db, nullptr, nullptr);
|
|
sqlite3_trace_v2(core.db.m_db, 0, nullptr, nullptr);
|
|
return sqlite3_exec(core.db.m_db, "DROP TRIGGER IF EXISTS refuse_nick", nullptr, nullptr, nullptr);
|
|
}), SQLITE_OK);
|
|
if (failure != "none" && failure != "missing") {
|
|
QVERIFY(save(dialog.get())); core.resume(); QTRY_COMPARE_WITH_TIMEOUT(accepted.count(), 1, 3000); core.pause();
|
|
QVERIFY(core.snapshot().startsWith(QString::fromUtf8("Новый ник\n").toUtf8()));
|
|
}
|
|
}
|
|
void lifetime_data() {
|
|
QTest::addColumn<bool>("properties"); QTest::addColumn<QString>("mode");
|
|
for (bool properties : {false, true}) for (const char* mode : {"close", "destroy", "stop", "disconnected", "other-db"})
|
|
QTest::newRow(qPrintable(QString("%1-%2").arg(properties ? "properties" : "rename", mode))) << properties << QString(mode);
|
|
}
|
|
void lifetime() {
|
|
QFETCH(bool, properties); QFETCH(QString, mode);
|
|
MemberCore core; QVERIFY(core.start()); auto dialog = makeDialog(core, properties);
|
|
dialog->findChild<QLineEdit*>()->setText("Later nickname"); QSignalSpy accepted(dialog.get(), &QDialog::accepted);
|
|
sqlite3* other = nullptr;
|
|
if (mode == "disconnected") core.db.setDb(nullptr);
|
|
if (mode == "other-db") { QCOMPARE(sqlite3_open(":memory:", &other), SQLITE_OK); core.db.setDb(other); }
|
|
QVERIFY(save(dialog.get())); QCOMPARE(accepted.count(), 0);
|
|
if (mode == "destroy") dialog.reset();
|
|
else if (mode == "close") dialog->reject();
|
|
if (mode == "stop") core.stop();
|
|
else if (mode != "disconnected") { core.run([] { return 0; }); }
|
|
QCoreApplication::processEvents();
|
|
if (mode == "close" || mode == "destroy") {
|
|
QVERIFY(core.snapshot().startsWith("Later nickname\n")); QCOMPARE(accepted.count(), 0);
|
|
if (dialog) QCOMPARE(dialog->result(), int(QDialog::Rejected));
|
|
} else { QCOMPARE(accepted.count(), 0); QVERIFY(!errorLabel(dialog.get())->text().isEmpty()); }
|
|
if (other) { core.db.setDb(core.instance->topo_sqlite_db); QCOMPARE(sqlite3_close(other), SQLITE_OK); }
|
|
}
|
|
void failedRead_data() {
|
|
QTest::addColumn<bool>("properties");
|
|
QTest::newRow("rename") << false; QTest::newRow("properties") << true;
|
|
}
|
|
void failedRead() {
|
|
QFETCH(bool, properties); MemberCore core; QVERIFY(core.start()); const auto before = core.snapshot();
|
|
QCOMPARE(core.run([&] { return sqlite3_set_authorizer(core.db.m_db,
|
|
[](void*, int action, const char*, const char* column, const char*, const char*) {
|
|
if (action == SQLITE_READ && column && !strcmp(column, "local_nick")) {
|
|
DEBUG_WARN(DEBUG_CATEGORY_CHAT_SYNC, "[INJECT] initial nickname read denied"); return SQLITE_DENY;
|
|
}
|
|
return SQLITE_OK;
|
|
}, nullptr); }), SQLITE_OK);
|
|
auto dialog = makeDialog(core, properties); QSignalSpy accepted(dialog.get(), &QDialog::accepted);
|
|
QCOMPARE(core.run([&] { return sqlite3_set_authorizer(core.db.m_db, nullptr, nullptr); }), SQLITE_OK);
|
|
QVERIFY(save(dialog.get())); QCOMPARE(accepted.count(), 0); QVERIFY(!errorLabel(dialog.get())->text().isEmpty());
|
|
QCOMPARE(core.snapshot(), before);
|
|
}
|
|
void staleOwnerAndUnchangedOwner() {
|
|
MemberCore core; QVERIFY(core.start()); auto dialog = makeDialog(core, true);
|
|
QCOMPARE(core.run([&] { return chat_core_save_member(core.instance, qPrintable(core.channel), core.node,
|
|
"Newer nickname", "{\"owner_test\":\"newer\"}"); }), 0);
|
|
const auto before = core.snapshot(); QSignalSpy accepted(dialog.get(), &QDialog::accepted);
|
|
dialog->findChild<QLineEdit*>()->setText("Stale nickname"); QVERIFY(save(dialog.get())); core.resume();
|
|
QTRY_VERIFY_WITH_TIMEOUT(!errorLabel(dialog.get())->text().isEmpty(), 3000); core.pause();
|
|
QCOMPARE(accepted.count(), 0); QCOMPARE(core.snapshot(), before);
|
|
int result = -99; QObject context;
|
|
QVERIFY(gui_bridge_save_member(core.db.m_db, core.channel, core.node, "", "{}", &context, [&](int rc) { result = rc; }));
|
|
core.resume(); QTRY_COMPARE_WITH_TIMEOUT(result, 0, 3000); core.pause();
|
|
QVERIFY(core.snapshot().startsWith('\n')); // No-op owner-блок не мешает снятию локального ника.
|
|
}
|
|
void validation() {
|
|
MemberCore core; QVERIFY(core.start()); QObject context; bool called = false;
|
|
const auto callback = [&](int) { called = true; };
|
|
QVERIFY(!gui_bridge_save_member(core.db.m_db, "01", core.node, "Nick", {}, &context, callback));
|
|
QVERIFY(!gui_bridge_save_member(core.db.m_db, core.channel, 0, "Nick", {}, &context, callback));
|
|
QVERIFY(!gui_bridge_save_member(core.db.m_db, core.channel, core.node, QString(QChar(0)), {}, &context, callback));
|
|
QVERIFY(!gui_bridge_save_member(core.db.m_db, core.channel, core.node, "Nick", QByteArray(8193, 'x'), &context, callback));
|
|
QVERIFY(!gui_bridge_save_member(core.db.m_db, core.channel, core.node, "Nick", {}, nullptr, callback));
|
|
QCoreApplication::processEvents(); QVERIFY(!called);
|
|
}
|
|
void roleSnapshot() {
|
|
MemberCore core; QVERIFY(core.start());
|
|
MemberPropsDialog dialog(&core.db, core.channel, core.node, "Member",
|
|
core.flags ^ (CHAT_MEMBER_FLAG_ADMIN | CHAT_MEMBER_FLAG_SUPERNODE));
|
|
const auto boxes = dialog.findChildren<QCheckBox*>(); QVERIFY(boxes.size() >= 2);
|
|
QCOMPARE(boxes[0]->isChecked(), bool(core.flags & CHAT_MEMBER_FLAG_ADMIN));
|
|
QCOMPARE(boxes[1]->isChecked(), bool(core.flags & CHAT_MEMBER_FLAG_SUPERNODE));
|
|
}
|
|
void transferOwnership() {
|
|
MemberCore core; QVERIFY(core.start());
|
|
QVERIFY(!gui_bridge_transfer_admin(nullptr, core.channel, core.node));
|
|
QVERIFY(!gui_bridge_transfer_admin(core.db.m_db, "01", core.node));
|
|
#ifdef UTUN_TEST_MEMBER_ALLOC_FAILURES
|
|
for (int allocation : {1, 2}) {
|
|
failCalloc = allocation; QVERIFY(!gui_bridge_transfer_admin(core.db.m_db, core.channel, core.node));
|
|
QCOMPARE(failCalloc, 0);
|
|
}
|
|
#endif
|
|
QVERIFY(gui_bridge_transfer_admin(core.db.m_db, core.channel, core.node));
|
|
core.stop(); QCoreApplication::processEvents(); // Owned transfer освобождается при отмене, не отправляя ключ.
|
|
}
|
|
#ifdef UTUN_TEST_MEMBER_ALLOC_FAILURES
|
|
void allocations_data() {
|
|
QTest::addColumn<bool>("properties"); QTest::addColumn<bool>("queue");
|
|
for (bool properties : {false, true}) for (bool queue : {false, true})
|
|
QTest::newRow(qPrintable(QString("%1-%2").arg(properties).arg(queue))) << properties << queue;
|
|
}
|
|
void allocations() {
|
|
QFETCH(bool, properties); QFETCH(bool, queue);
|
|
MemberCore core; QVERIFY(core.start()); auto dialog = makeDialog(core, properties);
|
|
auto before = core.snapshot(); QSignalSpy accepted(dialog.get(), &QDialog::accepted);
|
|
failMalloc = !queue; failCalloc = queue; QVERIFY(save(dialog.get()));
|
|
QCOMPARE(failMalloc, 0); QCOMPARE(failCalloc, 0); QCOMPARE(accepted.count(), 0); QCOMPARE(core.snapshot(), before);
|
|
QVERIFY(!errorLabel(dialog.get())->text().isEmpty());
|
|
QVERIFY(save(dialog.get())); core.resume(); QTRY_COMPARE_WITH_TIMEOUT(accepted.count(), 1, 3000); core.pause();
|
|
}
|
|
#endif
|
|
};
|
|
QTEST_MAIN(MemberDialogTest)
|
|
#include "test_member_dialogs.moc"
|