From e068fe5d3a4b82c9f1de4f29f441ad958a4fe932 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 21:31:20 +0200 Subject: [PATCH] Test poll codecs, voting rules and delayed source messages --- tests/Makefile.am | 5 ++ tests/test_chat_poll.c | 147 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 tests/test_chat_poll.c diff --git a/tests/Makefile.am b/tests/Makefile.am index 33dbe5e3..dda3fcb9 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -757,3 +757,8 @@ test_http_proxy_LDADD = $(COMMON_LIBS) check_PROGRAMS += test_tcp_io_flush test_tcp_io_flush_SOURCES = test_tcp_io_flush.c test_tcp_io_flush_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) +check_PROGRAMS += test_chat_poll + +test_chat_poll_SOURCES = test_chat_poll.c +test_chat_poll_CFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/src/chat -I$(top_srcdir)/lib +test_chat_poll_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) diff --git a/tests/test_chat_poll.c b/tests/test_chat_poll.c new file mode 100644 index 00000000..c0a7c267 --- /dev/null +++ b/tests/test_chat_poll.c @@ -0,0 +1,147 @@ +/* Кодеки и пользовательский API поверх настоящего ядра, подписей и SQLite. */ +#include "../src/chat/chat_feedback.c" +#include "../src/chat/chat_poll.h" +#include "../src/chat/chat_core.h" +#include "../src/config_parser.h" +#include + +static sqlite3_int64 json_number(sqlite3* db, const char* json, const char* path) { + sqlite3_stmt* st = NULL; + assert(sqlite3_prepare_v2(db, "SELECT json_extract(?1,?2)", -1, &st, NULL) == SQLITE_OK); + sqlite3_bind_text(st, 1, json, -1, SQLITE_STATIC); sqlite3_bind_text(st, 2, path, -1, SQLITE_STATIC); + assert(sqlite3_step(st) == SQLITE_ROW); sqlite3_int64 value = sqlite3_column_int64(st, 0); sqlite3_finalize(st); return value; +} + +static void json_string(sqlite3* db, const char* json, const char* path, char* out, size_t size) { + sqlite3_stmt* st = NULL; + assert(sqlite3_prepare_v2(db, "SELECT json_extract(?1,?2)", -1, &st, NULL) == SQLITE_OK); + sqlite3_bind_text(st, 1, json, -1, SQLITE_STATIC); sqlite3_bind_text(st, 2, path, -1, SQLITE_STATIC); + assert(sqlite3_step(st) == SQLITE_ROW && sqlite3_column_type(st, 0) == SQLITE_TEXT); + const char* text = (const char*)sqlite3_column_text(st, 0); assert(strlen(text) < size); + memcpy(out, text, strlen(text) + 1); sqlite3_finalize(st); +} + +static char* command(struct UTUN_INSTANCE* inst, const char* text, int expected) { + char* result = NULL; assert(chat_feedback_command(inst, text, &result) == expected && result); + assert(json_number(inst->topo_sqlite_db, "{\"valid\":1}", "$.valid") == 1); return result; +} + +/* Проверяем границы, каноничность и невозможность интерпретации битого UTF-8. */ +static void codecs(void) { + struct chat_poll poll = { .mode = CHAT_POLL_SINGLE, .allow_custom_answer = 1, .option_count = 2 }, decoded; + strcpy(poll.question, "Вопрос \"с кавычками\"\nи переносом"); strcpy(poll.options[0], "Да"); strcpy(poll.options[1], "Нет"); + uint8_t wire[CHAT_POLL_WIRE_MAX]; int n = chat_poll_encode(&poll, wire, sizeof(wire)); assert(n > 0); + assert(chat_poll_decode(wire, n, &decoded) == 0 && !memcmp(&poll, &decoded, sizeof(poll))); + for (int i = 0; i < n; i++) assert(chat_poll_decode(wire, i, &decoded) < 0); + uint8_t saved = wire[2]; wire[2] = 2; assert(chat_poll_decode(wire, n, &decoded) < 0); wire[2] = saved; + wire[n] = 0; assert(chat_poll_decode(wire, n + 1, &decoded) < 0); + strcpy(poll.options[1], poll.options[0]); assert(chat_poll_encode(&poll, wire, sizeof(wire)) < 0); + poll.options[1][0] = (char)0xc0; poll.options[1][1] = (char)0x80; poll.options[1][2] = 0; + assert(chat_poll_encode(&poll, wire, sizeof(wire)) < 0); + struct chat_poll_answer answer = { .count = 2, .choices = {3, 1} }, parsed; + strcpy(answer.custom, "Свой ответ"); + n = chat_answer_encode(&answer, wire, sizeof(wire)); assert(n > 0); + assert(chat_answer_decode(wire, n, &parsed) == 0 && parsed.choices[0] == 1 && parsed.choices[1] == 3); + wire[1] = 3; wire[2] = 1; assert(chat_answer_decode(wire, n, &parsed) < 0); + answer.choices[1] = 3; assert(chat_answer_encode(&answer, wire, sizeof(wire)) < 0); + memset(&answer, 0, sizeof(answer)); assert(chat_answer_encode(&answer, wire, sizeof(wire)) == 0); + assert(chat_answer_decode(NULL, 0, &parsed) == 0 && !parsed.count); + uint8_t empty[] = {0, 0, 0}; assert(chat_answer_decode(empty, sizeof(empty), &parsed) < 0); + uint8_t ids[] = {5, 0}, read[CHAT_REACTION_SELECTED_MAX]; size_t count; + n = chat_reactions_encode(ids, 2, wire, sizeof(wire)); assert(n == 3); + assert(chat_reactions_decode(wire, n, read, &count) == 0 && count == 2 && read[0] == 0 && read[1] == 5); + wire[2] = 0; assert(chat_reactions_decode(wire, n, read, &count) < 0); + assert(chat_reactions_encode(NULL, 0, wire, sizeof(wire)) == 0); + puts("PASS: canonical codecs, truncation, duplicate choices and UTF-8 rejection"); +} + +int main(void) { + debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR); codecs(); + struct UASYNC* ua = uasync_create(); assert(ua); + struct UTUN_INSTANCE* inst = utun_instance_create_from_str(ua, + "[global]\nmy_private_key=704f2e012c8fa8768130cb0f988a997dccb628372bc5ceccacc78dcbfec5916f\n" + "my_public_key=b3193173def895bd0fcea6f86af077c7d77216f10395275f627ac18242ec0f01\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"); + assert(inst && utun_core_start(inst) == 0 && chat_service_start(inst) == 0); + chat_core_create_channel_auto(inst, "Poll tests"); + 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); + assert(sqlite3_step(st) == SQLITE_ROW); char channel[21]; snprintf(channel, sizeof(channel), "%s", sqlite3_column_text(st, 0)); + sqlite3_finalize(st); + char request[4096], id[65]; uint8_t message_id[32]; + snprintf(request, sizeof(request), + "{\"cmd\":\"poll_create\",\"ch\":\"%s\",\"question\":\"Куда? \\\"Да\\\"\\nНовая строка\"," + "\"mode\":\"single\",\"options\":[\"Домой\",\"В парк\"],\"allow_custom_answer\":false}", channel); + char* result = command(inst, request, 0); json_string(db, result, "$.message_id", id, sizeof(id)); + assert(sc_hex_to_binary(id, message_id, 32) == 0); + assert(json_number(db, result, "$.poll.voters") == 0); + char question[1024]; json_string(db, result, "$.poll.question", question, sizeof(question)); + assert(!strcmp(question, "Куда? \"Да\"\nНовая строка")); u_free(result); + snprintf(request, sizeof(request), "{\"cmd\":\"vote\",\"ch\":\"%s\",\"message_id\":\"%s\",\"choices\":[0]}", channel, id); + result = command(inst, request, 0); assert(json_number(db, result, "$.poll.voters") == 1); + assert(json_number(db, result, "$.poll.options[0].count") == 1 && json_number(db, result, "$.poll.ver") == 1); u_free(result); + snprintf(request, sizeof(request), + "{\"cmd\":\"vote\",\"ch\":\"%s\",\"message_id\":\"%s\",\"choices\":[0,1]}", channel, id); + result = command(inst, request, -1); u_free(result); + snprintf(request, sizeof(request), + "{\"cmd\":\"vote\",\"ch\":\"%s\",\"message_id\":\"%s\",\"custom\":\"Свой вариант\"}", channel, id); + result = command(inst, request, -1); u_free(result); + snprintf(request, sizeof(request), "{\"cmd\":\"vote\",\"ch\":\"%s\",\"message_id\":\"%s\",\"choices\":[1]}", channel, id); + result = command(inst, request, 0); assert(json_number(db, result, "$.poll.options[0].count") == 0); + assert(json_number(db, result, "$.poll.options[1].count") == 1 && json_number(db, result, "$.poll.ver") == 2); u_free(result); + snprintf(request, sizeof(request), "{\"cmd\":\"vote\",\"ch\":\"%s\",\"message_id\":\"%s\"}", channel, id); + result = command(inst, request, 0); assert(json_number(db, result, "$.poll.voters") == 0); + assert(json_number(db, result, "$.poll.ver") == 3); u_free(result); + puts("PASS: single-choice rules, disabled custom answer, replacement and withdrawal"); + snprintf(request, sizeof(request), "{\"cmd\":\"react\",\"ch\":\"%s\",\"message_id\":\"%s\",\"emoji\":0}", channel, id); + result = command(inst, request, 0); assert(json_number(db, result, "$.reactions[0].count") == 1); u_free(result); + result = command(inst, request, 0); assert(json_number(db, result, "$.reaction_ver") == 2); u_free(result); + snprintf(request, sizeof(request), + "{\"cmd\":\"poll_create\",\"ch\":\"%s\",\"question\":\"Выбор\",\"mode\":\"multiple\"," + "\"options\":[\"Первый\",\"Второй\"],\"allow_custom_answer\":true}", channel); + result = command(inst, request, 0); json_string(db, result, "$.message_id", id, sizeof(id)); u_free(result); + assert(sc_hex_to_binary(id, message_id, 32) == 0); + snprintf(request, sizeof(request), + "{\"cmd\":\"vote\",\"ch\":\"%s\",\"message_id\":\"%s\",\"choices\":[1,0],\"custom\":\"Мой \\\"ответ\\\"\"}", channel, id); + result = command(inst, request, 0); + assert(json_number(db, result, "$.poll.voters") == 1 && json_number(db, result, "$.poll.custom_count") == 1); + assert(json_number(db, result, "$.poll.options[0].percent") == 100 && json_number(db, result, "$.poll.options[1].percent") == 100); + u_free(result); + /* Исходное сообщение временно отсутствует: ответ остаётся в Merkle, но не считается. */ + char sql[512]; snprintf(sql, sizeof(sql), "CREATE TEMP TABLE poll_backup AS SELECT * FROM msg_%s", channel); + assert(sqlite3_exec(db, sql, NULL, NULL, NULL) == SQLITE_OK); + snprintf(sql, sizeof(sql), "DELETE FROM msg_%s WHERE message_id=?", channel); + assert(sqlite3_prepare_v2(db, sql, -1, &st, NULL) == SQLITE_OK); + sqlite3_bind_blob(st, 1, message_id, 32, SQLITE_STATIC); assert(sqlite3_step(st) == SQLITE_DONE); sqlite3_finalize(st); + struct chat_feedback_record answer; + assert(chat_feedback_get(inst, channel, message_id, inst->node_id, CHAT_FEEDBACK_ANSWER, &answer) == 1); + answer.ver++; uint8_t body[FB_WIRE_MAX + 32]; int bytes = fb_signed_bytes(strtoull(channel, NULL, 10), &answer, body); + assert(bytes > 0 && sc_ed25519_sign(inst->my_ed25519_privkey, body, bytes, answer.signature) == SC_OK); + assert(chat_feedback_apply(inst, channel, &answer) == 1); + assert(chat_feedback_snapshot(inst, channel, message_id, &result) == 0 && json_number(db, result, "$.pending") == 1); + u_free(result); + snprintf(sql, sizeof(sql), "INSERT OR IGNORE INTO msg_%s SELECT * FROM poll_backup", channel); + assert(sqlite3_exec(db, sql, NULL, NULL, NULL) == SQLITE_OK); chat_feedback_check_message(inst, channel, message_id); + assert(chat_feedback_snapshot(inst, channel, message_id, &result) == 0 && json_number(db, result, "$.pending") == 0); + assert(json_number(db, result, "$.poll.voters") == 1 && json_number(db, result, "$.poll.ver") == 2); u_free(result); + /* Неверный подписанный ответ синхронизируется, но исключается из результатов. */ + struct chat_poll_answer wrong = { .count = 1, .choices = {7} }; + answer.data_len = chat_answer_encode(&wrong, answer.data, sizeof(answer.data)); answer.ver++; + bytes = fb_signed_bytes(strtoull(channel, NULL, 10), &answer, body); + assert(sc_ed25519_sign(inst->my_ed25519_privkey, body, bytes, answer.signature) == SC_OK); + assert(chat_feedback_apply(inst, channel, &answer) == 1); + assert(chat_feedback_snapshot(inst, channel, message_id, &result) == 0 && json_number(db, result, "$.poll.voters") == 0); + u_free(result); + char listing[16384]; size_t len; + assert(chat_core_get_messages_json(inst, channel, 20, 0, listing, sizeof(listing), &len) == 0 && strstr(listing, id)); + char tiny[8]; assert(chat_core_get_messages_json(inst, channel, 20, 0, tiny, sizeof(tiny), &len) < 0 && !len); + result = command(inst, "{\"cmd\":\"reaction_catalog\"}", 0); + assert(json_number(db, result, "$.catalog[0].id") == 0); u_free(result); + puts("PASS: multi-choice custom answers, delayed source, invalid signed answer and complete message listing"); + 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: poll API and codecs"); + return 0; +}