diff --git a/tests/Makefile.am b/tests/Makefile.am index 65c524f1..f5ecea7c 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -82,6 +82,7 @@ check_PROGRAMS = \ test_node_snapshot \ test_ncd_config \ test_db_sync \ + test_db_sync_schema \ test_merkle_sync \ test_merkle_protocol \ test_chat_feedback \ @@ -488,6 +489,10 @@ test_db_sync_SOURCES = test_db_sync.c test_db_sync_CFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/lib test_db_sync_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) +test_db_sync_schema_SOURCES = test_db_sync_schema.c +test_db_sync_schema_CFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/lib +test_db_sync_schema_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) + test_merkle_sync_SOURCES = test_merkle_sync.c test_chat_feedback_SOURCES = test_chat_feedback.c test_chat_feedback_CFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/src/chat -I$(top_srcdir)/lib diff --git a/tests/test_db_sync_schema.c b/tests/test_db_sync_schema.c new file mode 100644 index 00000000..4c7e559d --- /dev/null +++ b/tests/test_db_sync_schema.c @@ -0,0 +1,144 @@ +/* Сохранение настоящей истории при обновлении схемы, повторный запуск и атомарный отказ. */ +#include "../src/utun_instance.h" +#include "../src/config_parser.h" +#include "../src/chat/chat_core.h" +#include "../src/chat/chat_feedback.h" +#include "../src/chat/db_sync.h" +#include "../src/routing_layer/topo_group.h" +#include "../src/transport_layer/secure_channel.h" +#include "../lib/debug_config.h" +#include +#include +#include +#include + +#define OLD_COLUMNS "timestamp,node_id,id,chain_hash,flags,data,author_signature,local_attrs,delivered_peers,delivery_chain" + +static void execute(sqlite3* db, const char* sql) { + int rc = sqlite3_exec(db, sql, NULL, NULL, NULL); + if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "schema test SQL=%s: %s", sql, sqlite3_errmsg(db)); + assert(rc == SQLITE_OK); +} + +static sqlite3_int64 number(sqlite3* db, const char* sql) { + sqlite3_stmt* st = NULL; + assert(sqlite3_prepare_v2(db, sql, -1, &st, NULL) == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW); + sqlite3_int64 value = sqlite3_column_int64(st, 0); sqlite3_finalize(st); return value; +} + +static void legacy_table(sqlite3* db, const char* name) { + char sql[1024]; + snprintf(sql, sizeof(sql), "CREATE TABLE \"%s\" (timestamp INTEGER NOT NULL,node_id INTEGER NOT NULL," + "id INTEGER NOT NULL,chain_hash BLOB NOT NULL,flags INTEGER NOT NULL DEFAULT 0,data BLOB," + "author_signature BLOB NOT NULL,local_attrs TEXT DEFAULT '',delivered_peers INTEGER NOT NULL DEFAULT 0," + "delivery_chain TEXT NOT NULL DEFAULT '',PRIMARY KEY(timestamp,author_signature));" + "CREATE INDEX \"idx_%s_ttl\" ON \"%s\" (node_id,timestamp)", name, name, name); + execute(db, sql); +} + +static void check_history(sqlite3* db, const char* table, uint64_t group) { + char sql[768]; + snprintf(sql, sizeof(sql), "SELECT count(*) FROM (SELECT " OLD_COLUMNS " FROM \"%s\" EXCEPT SELECT * FROM expected)", table); + assert(number(db, sql) == 0); + snprintf(sql, sizeof(sql), "SELECT count(*) FROM (SELECT * FROM expected EXCEPT SELECT " OLD_COLUMNS " FROM \"%s\")", table); + assert(number(db, sql) == 0); + snprintf(sql, sizeof(sql), "SELECT timestamp,author_signature,message_id FROM \"%s\"", table); + sqlite3_stmt* st = NULL; assert(sqlite3_prepare_v2(db, sql, -1, &st, NULL) == SQLITE_OK); + int rc; + while ((rc = sqlite3_step(st)) == SQLITE_ROW) { + uint8_t id[32]; assert(sqlite3_column_bytes(st, 1) == 64 && sqlite3_column_bytes(st, 2) == 32); + assert(chat_message_id(group, (uint64_t)sqlite3_column_int64(st, 0), sqlite3_column_blob(st, 1), id) == 0); + assert(!memcmp(id, sqlite3_column_blob(st, 2), 32)); + } + assert(rc == SQLITE_DONE); sqlite3_finalize(st); + snprintf(sql, sizeof(sql), "SELECT count(*) FROM sqlite_master WHERE type='index' AND name='idx_%s_ttl'", table); + assert(number(db, sql) == 1 && number(db, "SELECT count(*) FROM sqlite_master WHERE name LIKE 'new_msg_%'") == 0); + assert(sqlite3_get_autocommit(db)); +} + +static struct chat_msg_submit send_text(struct UTUN_INSTANCE* inst, const char* channel, const char* text) { + struct chat_msg_submit req = { .inst = inst, .data = (const uint8_t*)text, .data_len = strlen(text) }; + snprintf(req.channel_id, sizeof(req.channel_id), "%s", channel); strcpy(req.content_type, "text"); + assert(chat_core_submit_message(inst, &req) == 0); return req; +} + +/* Дополнительный режим проверяет копии пользовательских БД через тот же публичный API. */ +static void check_copy(struct UTUN_INSTANCE* inst) { + sqlite3* db = inst->topo_sqlite_db; char** tables = NULL; int rows, columns; + assert(sqlite3_get_table(db, "SELECT name FROM sqlite_master WHERE type='table' AND name GLOB 'msg_[0-9]*'", + &tables, &rows, &columns, NULL) == SQLITE_OK); + assert(db_sync_enable(inst) == 0); + for (int i = 0; i < rows; i++) { + const char* table = tables[i + 1]; uint64_t gid = strtoull(table + 4, NULL, 10); assert(gid); + assert(topo_groups_create_group(inst->topo_groups, gid, TOPO_GROUP_TYPE_CHAT, table + 4)); + char sql[768]; snprintf(sql, sizeof(sql), "CREATE TEMP TABLE expected AS SELECT " OLD_COLUMNS " FROM \"%s\"", table); + execute(db, sql); struct DB_SYNC_INSTANCE* si = db_sync_instance_add(inst, table, gid); assert(si); + check_history(db, table, gid); db_sync_instance_remove(si); + si = db_sync_instance_add(inst, table, gid); assert(si); check_history(db, table, gid); db_sync_instance_remove(si); + execute(db, "DROP TABLE expected"); + } + sqlite3_free_table(tables); DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "PASS: user DB copy, tables=%d, all fields preserved", rows); +} + +int main(int argc, char** argv) { + debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); debug_set_category_level(DEBUG_CATEGORY_CHAT_SYNC, DEBUG_LEVEL_INFO); + struct UASYNC* ua = uasync_create(); assert(ua); char config[2048]; + snprintf(config, sizeof(config), + "[global]\nmy_private_key=704f2e012c8fa8768130cb0f988a997dccb628372bc5ceccacc78dcbfec5916f\n" + "my_public_key=b3193173def895bd0fcea6f86af077c7d77216f10395275f627ac18242ec0f01\n" + "db_path=%s\n[routing]\nmy_subnet=192.168.42.0/24\n[server: udp]\naddr=127.0.0.1:0\ntype=public\n" + "[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\n", argc == 2 ? argv[1] : ""); + struct UTUN_INSTANCE* inst = utun_instance_create_from_str(ua, config); assert(inst && utun_core_start(inst) == 0); + if (argc == 2) { check_copy(inst); goto done; } + assert(chat_service_start(inst) == 0); chat_core_create_channel_auto(inst, "Stored history"); + sqlite3* db = inst->topo_sqlite_db; sqlite3_stmt* st = NULL; + assert(sqlite3_prepare_v2(db, "SELECT channel_id FROM channels", -1, &st, NULL) == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW); + char channel[21], table[32], sql[1024]; snprintf(channel, sizeof(channel), "%s", sqlite3_column_text(st, 0)); sqlite3_finalize(st); + snprintf(table, sizeof(table), "msg_%s", channel); uint64_t gid = strtoull(channel, NULL, 10); + struct chat_msg_submit first = send_text(inst, channel, "История \"с кавычками\"\n👍"); + send_text(inst, channel, "Второе сообщение"); + /* Один timestamp и разные подписи должны получить разные стабильные ID. */ + const char* json = "{\"ct\":\"text\",\"d\":\"same timestamp\"}"; uint8_t body[128], signature[64]; + memcpy(body, &first.timestamp, 8); memcpy(body + 8, json, strlen(json)); + assert(sc_ed25519_sign(inst->my_ed25519_privkey, body, 8 + strlen(json), signature) == SC_OK); + assert(db_sync_insert_signed(db_sync_instance_find(inst, gid), json, strlen(json), signature, 64, first.timestamp, NULL) == 0); + uasync_poll(ua, 0); + snprintf(sql, sizeof(sql), "UPDATE \"%s\" SET flags=1,local_attrs=NULL,delivered_peers=7,delivery_chain='saved chain';" + "CREATE TEMP TABLE expected AS SELECT " OLD_COLUMNS " FROM \"%s\"", table, table); execute(db, sql); + chat_core_create_channel_auto(inst, "Empty history"); chat_service_stop(inst); + /* Воспроизводим старую схему всех каналов, чтобы тест покрывал DDL при загрузке списка каналов. */ + char** tables = NULL; int rows, columns; + assert(sqlite3_get_table(db, "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'msg_%'", + &tables, &rows, &columns, NULL) == SQLITE_OK); + for (int i = 0; i < rows; i++) { + const char* name = tables[i + 1]; snprintf(sql, sizeof(sql), "ALTER TABLE \"%s\" RENAME TO saved", name); execute(db, sql); + legacy_table(db, name); snprintf(sql, sizeof(sql), "INSERT INTO \"%s\" SELECT " OLD_COLUMNS " FROM saved;DROP TABLE saved", name); + execute(db, sql); + } + sqlite3_free_table(tables); + assert(chat_service_start(inst) == 0); check_history(db, table, gid); assert(chat_core_count(inst, channel) == 3); + char listing[8192]; size_t len; + assert(chat_core_get_messages_json(inst, channel, 10, 0, listing, sizeof(listing), &len) == 0 && len > 0); + snprintf(sql, sizeof(sql), "INSERT INTO \"%s\" (" OLD_COLUMNS ") SELECT * FROM expected", table); + assert(sqlite3_exec(db, sql, NULL, NULL, NULL) == SQLITE_CONSTRAINT); + snprintf(sql, sizeof(sql), "INSERT INTO \"%s\" (" OLD_COLUMNS ",message_id) SELECT *,x'01' FROM expected", table); + assert(sqlite3_exec(db, sql, NULL, NULL, NULL) == SQLITE_CONSTRAINT); + chat_service_stop(inst); assert(chat_service_start(inst) == 0); check_history(db, table, gid); + send_text(inst, channel, "Отправка после обновления 👍"); assert(chat_core_count(inst, channel) == 4); + DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "PASS: restart, empty table, preserved fields, canonical IDs and new message submission"); + /* Ошибка после копирования первой строки должна откатить всю перестройку. */ + assert(topo_groups_create_group(inst->topo_groups, 9001, TOPO_GROUP_TYPE_CHAT, "9001")); legacy_table(db, "msg_9001"); + execute(db, "INSERT INTO msg_9001 VALUES(1,1,1,zeroblob(32),0,x'01',zeroblob(64),'kept',0,'');" + "INSERT INTO msg_9001 VALUES(2,1,2,zeroblob(32),0,x'02',zeroblob(3),'kept',0,'')"); + assert(!db_sync_instance_add(inst, "msg_9001", 9001)); + assert(sqlite3_get_autocommit(db) && number(db, "SELECT count(*) FROM msg_9001") == 2); + assert(number(db, "SELECT count(*) FROM pragma_table_info('msg_9001') WHERE name='message_id'") == 0); + assert(number(db, "SELECT count(*) FROM sqlite_master WHERE name='new_msg_9001'") == 0); + execute(db, "UPDATE msg_9001 SET author_signature=zeroblob(64) WHERE timestamp=2"); + struct DB_SYNC_INSTANCE* si = db_sync_instance_add(inst, "msg_9001", 9001); assert(si && db_sync_count(si) == 2); + DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "PASS: partial-copy failure rolls back, registration can be retried"); +done: + utun_instance_destroy(inst); uasync_poll(ua, 0); + assert(ua->timer_alloc_count == ua->timer_free_count && ua->socket_alloc_count == ua->socket_free_count); + uasync_destroy(ua, 0); puts("ALL PASS: chat history schema repair"); return 0; +}