/* Реальные 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 #include #include #include #include #include #include #include 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 lock(mutex); stopping = true; wake.notify_all(); } if (worker.joinable()) worker.join(); db.setDb(nullptr); } ~MemberCore() { stop(); } bool start() { std::promise 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(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 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 lock(mutex); paused = false; wake.notify_all(); } void pause() { std::unique_lock lock(mutex); paused = true; wake.wait(lock, [this] { return !active; }); } int run(std::function function) { auto task = std::make_unique>(std::move(function)); auto result = task->get_future(); gui_bridge_post_uasync_fn([](void* arg) { std::unique_ptr> task(static_cast*>(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(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 makeDialog(MemberCore& core, bool properties) { if (properties) return std::make_unique(&core.db, core.channel, core.node, "Member", core.flags); return std::make_unique(&core.db, core.channel, core.node, "Member"); } static QLabel* errorLabel(QDialog* dialog) { return dialog->findChild("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("properties"); QTest::addColumn("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(arg); const char* sql = sqlite3_sql(static_cast(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(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(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()->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()->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("properties"); QTest::addColumn("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()->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("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()->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(); 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("properties"); QTest::addColumn("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"