diff --git a/tests/Makefile.am b/tests/Makefile.am index 7e6a2132..877913b4 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -114,6 +114,7 @@ check_PROGRAMS += \ test_config_updater \ test_stun_nat \ test_chat_admin \ + test_chat_attachment \ test_db_sync_integrity \ test_reality_fallback \ test_etcp_padding \ @@ -462,6 +463,13 @@ test_encrypted_attachment_LDFLAGS = $(test_clock_ldflags) if OS_LINUX test_encrypted_attachment_LDFLAGS += -Wl,--wrap=uasync_set_timeout -Wl,--wrap=media_download_handle_chunk endif +if OS_LINUX +test_chat_attachment_SOURCES = test_chat_attachment.c +test_chat_attachment_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) +test_chat_attachment_LDFLAGS = -Wl,--wrap=sqlite3_exec -Wl,--wrap=sqlite3_prepare_v2 -Wl,--wrap=sqlite3_bind_blob \ + -Wl,--wrap=sqlite3_bind_text -Wl,--wrap=sqlite3_bind_int64 -Wl,--wrap=sqlite3_step -Wl,--wrap=sqlite3_finalize \ + -Wl,--wrap=media_async_submit +endif test_attachment_send_SOURCES = test_attachment_send.c test_attachment_send_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) if OS_LINUX diff --git a/tests/test_chat_attachment.c b/tests/test_chat_attachment.c new file mode 100644 index 00000000..b6efcd83 --- /dev/null +++ b/tests/test_chat_attachment.c @@ -0,0 +1,286 @@ +/* Настоящие SQLite/crypto/workers: commit авторского ключа до передачи source и единственный результат. */ +#define _GNU_SOURCE +#include +#include +#include +#include "../src/utun_instance.h" +#include "../src/chat/chat_core.h" +#include "../src/chat/chat_attachment.h" +#include "../src/chat/chat_event.h" +#include "../src/media_async/media_async.h" +#include "../lib/platform_compat.h" +#include "../lib/debug_config.h" +#include "../lib/mem.h" + +enum fault { NONE, BEGIN, PREPARE, BIND_1, BIND_2, BIND_3, BIND_4, BIND_5, BIND_6, BIND_7, BIND_8, + STEP, FINALIZE, COMMIT, FAULT_COUNT }; +static enum fault fault; +static unsigned hits[FAULT_COUNT], rollback_fault; +static int fail_rollback, fail_submit; +static const char* insert_prefix = "INSERT INTO attachment_keys(channel,crypto_id,author,key,"; +static const char content[] = "attachment source must survive SQL failure"; + +struct fixture { + struct UASYNC* ua; + struct UTUN_INSTANCE* inst; + sqlite3* reader; + char directory[256], source[512], channel[32]; + uint8_t id[16], key[32]; + unsigned rows, events, submissions; + int success, inspect_submit; +}; +static struct fixture* active; + +static int take(enum fault target) { + if (fault != target) return 0; + fault = NONE; hits[target]++; + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment test: injected stage=%d", target); return 1; +} + +static int is_preparation(sqlite3_stmt* st) { + return st && !strncmp(sqlite3_sql(st), insert_prefix, strlen(insert_prefix)); +} + +extern int __real_sqlite3_exec(sqlite3*, const char*, int (*)(void*, int, char**, char**), void*, char**); +int __wrap_sqlite3_exec(sqlite3* db, const char* sql, int (*cb)(void*, int, char**, char**), void* arg, char** error) { + if (!strcmp(sql, "BEGIN IMMEDIATE") && take(BEGIN)) return SQLITE_BUSY; + if (!strcmp(sql, "COMMIT") && take(COMMIT)) return SQLITE_BUSY; + if (!strcmp(sql, "ROLLBACK") && fail_rollback) { + fail_rollback = 0; rollback_fault++; + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "attachment test: injected rollback failure"); return SQLITE_IOERR; + } + return __real_sqlite3_exec(db, sql, cb, arg, error); +} + +extern int __real_sqlite3_prepare_v2(sqlite3*, const char*, int, sqlite3_stmt**, const char**); +int __wrap_sqlite3_prepare_v2(sqlite3* db, const char* sql, int length, sqlite3_stmt** st, const char** tail) { + if (!strncmp(sql, insert_prefix, strlen(insert_prefix)) && take(PREPARE)) { *st = NULL; return SQLITE_NOMEM; } + return __real_sqlite3_prepare_v2(db, sql, length, st, tail); +} + +extern int __real_sqlite3_bind_blob(sqlite3_stmt*, int, const void*, int, void (*)(void*)); +int __wrap_sqlite3_bind_blob(sqlite3_stmt* st, int index, const void* data, int length, void (*destroy)(void*)) { + if (is_preparation(st)) { + if (index == 2) { assert(length == 16); memcpy(active->id, data, 16); } + if (index == 4) { assert(length == 32); memcpy(active->key, data, 32); } + if (take((enum fault)(BIND_1 + index - 1))) return SQLITE_NOMEM; + } + return __real_sqlite3_bind_blob(st, index, data, length, destroy); +} + +extern int __real_sqlite3_bind_text(sqlite3_stmt*, int, const char*, int, void (*)(void*)); +int __wrap_sqlite3_bind_text(sqlite3_stmt* st, int index, const char* data, int length, void (*destroy)(void*)) { + if (is_preparation(st) && take((enum fault)(BIND_1 + index - 1))) return SQLITE_NOMEM; + return __real_sqlite3_bind_text(st, index, data, length, destroy); +} + +extern int __real_sqlite3_bind_int64(sqlite3_stmt*, int, sqlite3_int64); +int __wrap_sqlite3_bind_int64(sqlite3_stmt* st, int index, sqlite3_int64 value) { + if (is_preparation(st) && take((enum fault)(BIND_1 + index - 1))) return SQLITE_NOMEM; + return __real_sqlite3_bind_int64(st, index, value); +} + +extern int __real_sqlite3_step(sqlite3_stmt*); +int __wrap_sqlite3_step(sqlite3_stmt* st) { + if (is_preparation(st) && take(STEP)) return SQLITE_IOERR; + return __real_sqlite3_step(st); +} + +extern int __real_sqlite3_finalize(sqlite3_stmt*); +int __wrap_sqlite3_finalize(sqlite3_stmt* st) { + int failed = is_preparation(st) && take(FINALIZE); + int rc = __real_sqlite3_finalize(st); return failed ? SQLITE_IOERR : rc; +} + +static void sql(sqlite3* db, const char* text) { + int rc = sqlite3_exec(db, text, NULL, NULL, NULL); + if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "attachment test SQL rc=%d: %s", rc, sqlite3_errmsg(db)); + assert(rc == SQLITE_OK); +} + +static unsigned rows(struct fixture* f) { + sqlite3_stmt* st = NULL; + assert(sqlite3_prepare_v2(f->reader, "SELECT count(*) FROM attachment_keys", -1, &st, NULL) == SQLITE_OK); + assert(sqlite3_step(st) == SQLITE_ROW); + unsigned count = (unsigned)sqlite3_column_int(st, 0); + assert(sqlite3_finalize(st) == SQLITE_OK); return count; +} + +/* Читаем точные bytes всех полей с другого соединения: незавершённый commit здесь не виден. */ +static void committed(struct fixture* f, int published) { + sqlite3_stmt* st = NULL; + assert(sqlite3_get_autocommit(f->inst->topo_sqlite_db)); + assert(sqlite3_prepare_v2(f->reader, "SELECT key,author,info,content_type,cipher_path,plain_path,message_id,ready" + " FROM attachment_keys WHERE channel=? AND crypto_id=?", -1, &st, NULL) == SQLITE_OK); + assert(sqlite3_bind_text(st, 1, f->channel, -1, SQLITE_STATIC) == SQLITE_OK); + assert(sqlite3_bind_blob(st, 2, f->id, 16, SQLITE_STATIC) == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW); + assert(sqlite3_column_bytes(st, 0) == 32 && !memcmp(sqlite3_column_blob(st, 0), f->key, 32)); + assert((uint64_t)sqlite3_column_int64(st, 1) == f->inst->node_id); + struct attachment_info info = {.kind = ATTACHMENT_FILE, .name = "source.bin"}; + uint8_t encoded[ATTACHMENT_WIRE_MAX]; int length = attachment_encode(&info, encoded, sizeof(encoded)); + assert(length > 0 && sqlite3_column_bytes(st, 2) == length && !memcmp(sqlite3_column_blob(st, 2), encoded, (size_t)length)); + assert(!strcmp((const char*)sqlite3_column_text(st, 3), "application/octet-stream")); + char hex[33], expected[512]; + for (unsigned i = 0; i < 16; i++) snprintf(hex + i * 2, 3, "%02x", f->id[i]); + snprintf(expected, sizeof(expected), "%s/media/%s/%s.uenc", f->directory, f->channel, hex); + assert(!strcmp((const char*)sqlite3_column_text(st, 4), expected)); + snprintf(expected, sizeof(expected), "%s/decrypted/%s/%s.file", f->directory, f->channel, hex); + assert(!strcmp((const char*)sqlite3_column_text(st, 5), expected)); + assert(sqlite3_column_bytes(st, 6) == (published ? 32 : 0) && sqlite3_column_int(st, 7) == 1); + assert(sqlite3_step(st) == SQLITE_DONE && sqlite3_finalize(st) == SQLITE_OK); +} + +extern void __real_media_async_submit(struct media_async*, struct UASYNC*, ma_work_fn, void*, ma_done_fn, void*); +void __wrap_media_async_submit(struct media_async* ma, struct UASYNC* ua, ma_work_fn work, void* data, ma_done_fn done, void* arg) { + if (active && active->inspect_submit) { + active->inspect_submit = 0; active->submissions++; + committed(active, 0); + if (fail_submit) { fail_submit = 0; done(arg, -1); return; } + } + __real_media_async_submit(ma, ua, work, data, done, arg); +} + +static void event(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int length) { + if (type != CHAT_EVT_MSG_SUBMITTED) return; + assert(active && active->inst == inst && length == 9); + active->events++; active->success = data[8]; +} + +static void source(struct fixture* f) { + FILE* file = fopen(f->source, "wb"); assert(file); + assert(fwrite(content, 1, sizeof(content), file) == sizeof(content) && !fclose(file)); + f->events = f->submissions = 0; f->success = -1; f->inspect_submit = 1; +} + +static void source_preserved(struct fixture* f) { + char buffer[sizeof(content)]; FILE* file = fopen(f->source, "rb"); assert(file); + assert(fread(buffer, 1, sizeof(buffer), file) == sizeof(buffer) && !memcmp(buffer, content, sizeof(buffer))); + assert(fgetc(file) == EOF && !fclose(file)); +} + +static int send(struct fixture* f) { + struct attachment_info info = {.kind = ATTACHMENT_FILE, .name = "source.bin"}; uint8_t reply[32] = {0}; + return chat_attachment_send(f->inst, f->channel, f->source, &info, "application/octet-stream", reply, 71); +} + +static void unchanged(struct fixture* f) { + assert(fault == NONE && rows(f) == f->rows && !f->events && !f->submissions); + assert(sqlite3_get_autocommit(f->inst->topo_sqlite_db)); source_preserved(f); + sqlite3_stmt* st = NULL; + assert(sqlite3_prepare_v2(f->reader, "SELECT key FROM attachment_keys WHERE channel='900'", -1, &st, NULL) == SQLITE_OK); + assert(sqlite3_step(st) == SQLITE_ROW && sqlite3_column_bytes(st, 0) == 32); + uint8_t expected[32] = {85}; assert(!memcmp(sqlite3_column_blob(st, 0), expected, 32)); + assert(sqlite3_step(st) == SQLITE_DONE && sqlite3_finalize(st) == SQLITE_OK); +} + +static void publish(struct fixture* f) { + assert(!send(f) && f->submissions == 1); f->rows++; + for (unsigned poll = 0; poll < 2000 && !f->events; poll++) uasync_poll(f->ua, 10); + assert(f->events == 1 && f->success == 1 && rows(f) == f->rows && ma_file_size(f->source) < 0); + committed(f, 1); uasync_poll(f->ua, 0); assert(f->events == 1); +} + +static void setup(struct fixture* f) { + memset(f, 0, sizeof(*f)); strcpy(f->directory, "/tmp/utun-attachment-contract-XXXXXX"); assert(mkdtemp(f->directory)); + char config[1024], database[512]; + snprintf(config, sizeof(config), "[global]\ndb_path=%s\n[ntp]\nenabled=no\n" + "[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\n", f->directory); + f->ua = uasync_create(); assert(f->ua); f->inst = utun_instance_create_from_str(f->ua, config); + assert(f->inst && !utun_core_start(f->inst) && !chat_service_start(f->inst)); + active = f; chat_event_set_handler(f->inst, event); chat_core_create_channel_auto(f->inst, "attachment contract"); + sqlite3_stmt* st = NULL; + assert(sqlite3_prepare_v2(f->inst->topo_sqlite_db, "SELECT channel_id FROM channels WHERE name='attachment contract'", + -1, &st, NULL) == SQLITE_OK && sqlite3_step(st) == SQLITE_ROW); + snprintf(f->channel, sizeof(f->channel), "%s", sqlite3_column_text(st, 0)); assert(sqlite3_finalize(st) == SQLITE_OK); + sql(f->inst->topo_sqlite_db, "INSERT INTO attachment_keys(channel,crypto_id,author,key,cipher_path,plain_path)" + " VALUES('900',zeroblob(16),999,x'5500000000000000000000000000000000000000000000000000000000000000','','')," + "('901',zeroblob(16),999,NULL,'','')"); + snprintf(database, sizeof(database), "%s/chats.db", f->directory); + assert(sqlite3_open_v2(database, &f->reader, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK); + snprintf(f->source, sizeof(f->source), "%s/source.bin", f->directory); f->rows = rows(f); assert(f->rows == 2); +} + +static void cleanup(struct fixture* f) { + chat_event_set_handler(f->inst, NULL); utun_instance_destroy(f->inst); f->inst = NULL; + assert(sqlite3_close(f->reader) == SQLITE_OK); uasync_destroy(f->ua, 0); active = NULL; +} + +static void count_files(void* arg, const char* path, int64_t bytes) { (void)path; (void)bytes; (*(unsigned*)arg)++; } + +static void test_sql_faults(void) { + struct fixture f; setup(&f); + for (enum fault stage = BEGIN; stage < FAULT_COUNT; stage++) { + source(&f); unsigned before = 0, after = 0; assert(!ma_dir_walk(f.directory, count_files, &before)); + fault = stage; assert(send(&f) == -1); unchanged(&f); + assert(!ma_dir_walk(f.directory, count_files, &after) && before == after); + source(&f); publish(&f); + } + cleanup(&f); + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS attachment:13 SQL faults preserve source/rows/files, no worker/event; every retry publishes"); +} + +static int veto_commit(void* arg) { (*(unsigned*)arg)++; return 1; } + +static void test_sql_controls(void) { + struct fixture f; setup(&f); unsigned vetoes = 0; + source(&f); sqlite3_commit_hook(f.inst->topo_sqlite_db, veto_commit, &vetoes); + assert(send(&f) == -1 && vetoes == 1); sqlite3_commit_hook(f.inst->topo_sqlite_db, NULL, NULL); unchanged(&f); + source(&f); publish(&f); + const char* triggers[] = { + "CREATE TRIGGER reject_preparation BEFORE INSERT ON attachment_keys BEGIN SELECT RAISE(ABORT,'test insert failure'); END", + "CREATE TRIGGER reject_preparation BEFORE INSERT ON attachment_keys BEGIN SELECT RAISE(IGNORE); END" + }; + for (unsigned i = 0; i < 2; i++) { + source(&f); sql(f.inst->topo_sqlite_db, triggers[i]); assert(send(&f) == -1); unchanged(&f); + sql(f.inst->topo_sqlite_db, "DROP TRIGGER reject_preparation"); source(&f); publish(&f); + } + source(&f); sql(f.inst->topo_sqlite_db, "BEGIN; UPDATE attachment_keys SET ready=7 WHERE channel='900'"); + assert(send(&f) == -1 && !f.events && !f.submissions && !sqlite3_get_autocommit(f.inst->topo_sqlite_db)); + source_preserved(&f); assert(rows(&f) == f.rows); + sqlite3_stmt* st = NULL; + assert(sqlite3_prepare_v2(f.inst->topo_sqlite_db, "SELECT ready FROM attachment_keys WHERE channel='900'", -1, &st, NULL) == SQLITE_OK); + assert(sqlite3_step(st) == SQLITE_ROW && sqlite3_column_int(st, 0) == 7 && sqlite3_finalize(st) == SQLITE_OK); + sql(f.inst->topo_sqlite_db, "ROLLBACK"); unchanged(&f); source(&f); publish(&f); + chat_service_stop(f.inst); assert(!chat_service_start(f.inst)); committed(&f, 1); + cleanup(&f); + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS attachment: real commit veto, INSERT abort/ignore, external transaction preserved, retry/restart"); +} + +/* Submit может вызвать done до возврата send; SQL уже виден reader, результат остаётся единственным. */ +static void test_inline_failure(void) { + struct fixture f; setup(&f); source(&f); fail_submit = 1; + assert(!send(&f) && !fail_submit && f.submissions == 1 && f.events == 1 && !f.success); + assert(rows(&f) == f.rows + 1 && ma_file_size(f.source) < 0); committed(&f, 0); + uasync_poll(f.ua, 0); assert(f.events == 1); cleanup(&f); + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS attachment: inline submit failure after commit transfers source and reports exactly once"); +} + +static void fail_from_callback(void* arg) { + struct fixture* f = arg; fault = COMMIT; fail_rollback = 1; + assert(send(f) == -1 && !fail_rollback && !f->inst->running && !f->submissions && !f->events); + assert(!sqlite3_get_autocommit(f->inst->topo_sqlite_db)); source_preserved(f); +} + +/* Callback только запрашивает stop; владелец после poll уничтожает живое ядро и закрывает БД. */ +static void test_rollback_failure(void) { + struct fixture f; setup(&f); source(&f); + assert(uasync_post(f.ua, fail_from_callback, &f) == ERR_OK); + while (f.inst->running) uasync_poll(f.ua, 10); + assert(f.inst->chat_started && f.inst->chat_core && rows(&f) == f.rows); + assert(send(&f) == -1 && !f.events && !f.submissions); source_preserved(&f); + chat_event_set_handler(f.inst, NULL); utun_instance_destroy(f.inst); f.inst = NULL; + assert(rows(&f) == f.rows); uasync_poll(f.ua, 0); + assert(sqlite3_close(f.reader) == SQLITE_OK); uasync_destroy(f.ua, 0); active = NULL; + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS attachment: failed rollback requests owner stop, no worker/source transfer, close rolls back uncommitted row"); +} + +int main(void) { + debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR); debug_set_category_level(DEBUG_CATEGORY_MEDIA, DEBUG_LEVEL_INFO); + utun_instance_set_tun_init_enabled(0); size_t baseline = u_get_allocated_count(); + test_sql_faults(); test_sql_controls(); test_inline_failure(); test_rollback_failure(); + assert(fault == NONE && !fail_rollback && !fail_submit && rollback_fault == 1); + for (unsigned i = BEGIN; i < FAULT_COUNT; i++) assert(hits[i] == (i == COMMIT ? 2 : 1)); + assert(u_get_allocated_count() == baseline); + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "PASS attachment: independent committed key/metadata before worker, allocations restored"); + return 0; +}