From 4b394b75e08a4012f52dbe5227ccb55f2de1eab7 Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 26 Oct 2026 14:00:25 +0100 Subject: [PATCH] Cover feedback revocation, purge, delayed cross-channel credentials and transactional faults --- tests/test_chat_feedback.c | 165 +++++++++++++++++++++++++++++++++++++ tests/test_member_purge.c | 29 +++++++ 2 files changed, 194 insertions(+) diff --git a/tests/test_chat_feedback.c b/tests/test_chat_feedback.c index cac78564..a3be4c0d 100644 --- a/tests/test_chat_feedback.c +++ b/tests/test_chat_feedback.c @@ -68,6 +68,170 @@ static void check_author_rejection(struct UTUN_INSTANCE* inst) { puts("PASS: root author requires a valid channel signature; zero, missing and forged certificates are rejected"); } +/* Решение владельца проходит штатную проверку подписи и транзакцию MEMBERS. */ +static int set_deleted(struct UTUN_INSTANCE* dst, const struct UTUN_INSTANCE* author, unsigned ver, int deleted) { + char tags[96]; uint8_t body[128], sig[64]; + snprintf(tags, sizeof(tags), "{\"ver\":\"%u\",\"deleted\":\"%s\"}", ver, deleted ? "yes" : "no"); + int len = member_sync_build_owner_msg(author->node_id, tags, body, sizeof(body)); assert(len > 0); + assert(sc_ed25519_sign(owner_private, body, len, sig) == SC_OK); + return member_sync_put(dst, "1001", author->node_id, author->my_keys.public_key, author->my_ed25519_pubkey, + NULL, 0, NULL, 0, NULL, tags, sig, 0, 0, NULL); +} + +static void assert_stored(struct UTUN_INSTANCE* inst, const struct chat_feedback_record* expected) { + struct chat_feedback_record actual; + assert(chat_feedback_get(inst, "1001", expected->message_id, expected->node_id, expected->kind, &actual) == 1); + assert(actual.ver == expected->ver && actual.data_len == expected->data_len); + assert(!memcmp(actual.data, expected->data, actual.data_len) && !memcmp(actual.signature, expected->signature, 64)); +} + +static int deny_node_keys(void* arg, int action, const char* table, const char* column, const char* db, const char* trigger) { + (void)arg; (void)column; (void)db; (void)trigger; + return action == SQLITE_READ && table && !strcmp(table, "nodes") ? SQLITE_DENY : SQLITE_OK; +} + +static int veto_commit(void* arg) { int* remaining = arg; return (*remaining)-- > 0; } + +/* Исключение, purge и ожидание signer не уничтожают последние состояния и отмены. */ +static void membership_regressions(struct UASYNC* ua) { + struct UTUN_INSTANCE author, target, inviter; setup(&author, ua); setup(&target, ua); setup(&inviter, ua); + for (unsigned ch = 0; ch < 2; ch++) { + const char* channel = ch ? "1002" : "1001"; + add_member(&author, &author, channel); add_member(&target, &author, channel); add_member(&target, &target, channel); + } + uint8_t message[32] = {11}, cancelled_message[32] = {12}, reaction[] = {1, 0}, answer[] = {1, 0, 0, 0}; + struct chat_feedback_record original[3], newer, local; + assert(chat_feedback_set(&author, "1001", message, CHAT_FEEDBACK_REACTION, reaction, sizeof(reaction), NULL) == 1); + assert(chat_feedback_get(&author, "1001", message, author.node_id, CHAT_FEEDBACK_REACTION, &original[0]) == 1); + assert(chat_feedback_set(&author, "1001", message, CHAT_FEEDBACK_ANSWER, answer, sizeof(answer), NULL) == 1); + assert(chat_feedback_get(&author, "1001", message, author.node_id, CHAT_FEEDBACK_ANSWER, &original[1]) == 1); + assert(chat_feedback_set(&author, "1001", cancelled_message, CHAT_FEEDBACK_REACTION, reaction, sizeof(reaction), NULL) == 1); + assert(chat_feedback_set(&author, "1001", cancelled_message, CHAT_FEEDBACK_REACTION, NULL, 0, NULL) == 1); + assert(chat_feedback_get(&author, "1001", cancelled_message, author.node_id, CHAT_FEEDBACK_REACTION, &original[2]) == 1); + for (unsigned i = 0; i < 3; i++) assert(chat_feedback_apply(&target, "1001", &original[i]) == 1); + assert(chat_feedback_set(&author, "1002", message, CHAT_FEEDBACK_REACTION, reaction, sizeof(reaction), NULL) == 1); + struct chat_feedback_record other = original[0]; sign_record(&author, 1002, &other); + assert(chat_feedback_apply(&target, "1002", &other) == 1); + uint8_t before[32], after[32], empty[32] = {0}, other_root[32], key[32]; root(&target, before); + assert(merkle_sync_read_hash(&target, MT_DATA_FEEDBACK, "1002", 0, 0, other_root) == 0); + int saved_events = events; + assert(set_deleted(&target, &author, 1, 1) & MS_APPLY_CHANGED); + root(&target, after); assert(!memcmp(after, empty, 32) && events > saved_events); + assert(member_sync_author_key(&target, "1001", author.node_id, key) == MS_AUTHOR_INACTIVE); + assert(member_sync_author_key(&target, "1001", target.node_id, key) == MS_AUTHOR_ACTIVE); + assert(member_sync_author_key(&target, "1002", author.node_id, key) == MS_AUTHOR_ACTIVE); + assert(merkle_sync_read_hash(&target, MT_DATA_FEEDBACK, "1002", 0, 0, after) == 0 && !memcmp(after, other_root, 32)); + for (unsigned i = 0; i < 3; i++) assert_stored(&target, &original[i]); + merkle_key_t cursor = {{0}}, record_key, next; int more; uint8_t page[2 + 2 * FB_WIRE_MAX]; size_t bytes = sizeof(page); + assert(fb_key(1001, &original[0], &record_key) == 0); + assert(fb_page(&target, "1001", merkle_sync_level_prefix(fb_read64(record_key.bytes), 5), + 0, &cursor, page, &bytes, &next, &more) == 0 && bytes == 2 && !page[0] && !page[1] && !more); + member_sync_destroy(&target); assert(member_sync_init(&target) == 0 && chat_feedback_init(&target) == 0); + root(&target, after); assert(!memcmp(after, empty, 32)); + for (unsigned i = 0; i < 3; i++) assert_stored(&target, &original[i]); + assert(set_deleted(&author, &author, 1, 1) & MS_APPLY_CHANGED); + assert(chat_feedback_set(&author, "1001", message, CHAT_FEEDBACK_REACTION, reaction, sizeof(reaction), NULL) < 0); + assert(chat_feedback_set(&author, "1001", message, CHAT_FEEDBACK_REACTION, NULL, 0, NULL) < 0); + assert(set_deleted(&author, &author, 2, 0) & MS_APPLY_CHANGED); + newer = original[0]; newer.ver++; newer.data[1] = 1; sign_record(&author, 1001, &newer); + saved_events = events; + assert(chat_feedback_apply(&target, "1001", &newer) == 0 && events == saved_events); + assert_stored(&target, &original[0]); + assert(set_deleted(&target, &author, 2, 0) & MS_APPLY_CHANGED); + root(&target, after); assert(!memcmp(before, after, 32)); + assert(chat_feedback_apply(&target, "1001", &newer) == 1); original[0] = newer; + puts("PASS: exclusion/restart retains reaction, answer and cancellation; local no-op is denied; other channel is intact"); + + /* Старая страница исключённого автора не блокирует корректное состояние другого. */ + assert(set_deleted(&target, &author, 3, 1) & MS_APPLY_CHANGED); + local = original[0]; local.node_id = target.node_id; local.ver = 1; sign_record(&target, 1001, &local); + page[0] = 0; page[1] = 2; int n = fb_encode(&newer, page + 2), m = fb_encode(&local, page + 2 + n); + assert(n > 0 && m > 0 && fb_apply_page(&target, "1001", target.node_id, page, 2 + n + m) == 0); + assert_stored(&target, &local); assert_stored(&target, &original[0]); + assert(set_deleted(&target, &author, 4, 0) & MS_APPLY_CHANGED); + root(&target, before); + /* Короткое повреждение сертификата удаляет только участника, а не его feedback. */ + sqlite3_stmt* st = NULL; + assert(sqlite3_prepare_v2(target.topo_sqlite_db, "UPDATE peers_1001 SET signature=x'01' WHERE node_id=?", -1, &st, NULL) == SQLITE_OK); + assert(sqlite3_bind_int64(st, 1, (sqlite3_int64)author.node_id) == SQLITE_OK); + assert(sqlite3_step(st) == SQLITE_DONE); assert(sqlite3_finalize(st) == SQLITE_OK); + assert(member_sync_verify_and_purge(&target, "1001") == 1); + assert(member_sync_author_key(&target, "1001", author.node_id, key) == MS_AUTHOR_INACTIVE); + for (unsigned i = 0; i < 3; i++) assert_stored(&target, &original[i]); + member_sync_destroy(&target); assert(member_sync_init(&target) == 0 && chat_feedback_init(&target) == 0); + add_member(&target, &author, "1001"); root(&target, after); assert(!memcmp(before, after, 32)); + struct chat_feedback_record old_cancel = original[2]; old_cancel.ver = 1; old_cancel.data_len = sizeof(reaction); + memcpy(old_cancel.data, reaction, sizeof(reaction)); sign_record(&author, 1001, &old_cancel); + assert(chat_feedback_apply(&target, "1001", &old_cancel) == 0); assert_stored(&target, &original[2]); + puts("PASS: mixed page, physical purge and restored membership preserve the version barrier of cancellations"); + + /* Подтверждение автора зависит от ещё неизвестного общего ключа пригласившего. */ + uint8_t channel_x[32] = {3}, channel_ed[32], body[112], join[64], invitation[64]; public_key(owner_private, channel_ed); + assert(member_sync_build_join_msg(channel_x, channel_ed, author.node_id, author.my_keys.public_key, 2, body, sizeof(body)) == 112); + assert(sc_ed25519_sign(author.my_ed25519_privkey, body, sizeof(body), join) == SC_OK); + assert(member_sync_sign_pubkey(inviter.my_ed25519_privkey, author.my_keys.public_key, invitation) == 0); + struct ms_member_rec update = { .node_id = author.node_id, .x25519 = author.my_keys.public_key, + .ed25519 = author.my_ed25519_pubkey, .join_sig = join, .join_ts = 2, .userinfo = "", + .signed_by = inviter.node_id, .signature = invitation }; + assert(member_sync_apply_record(&target, "1001", author.node_id, &update) & MS_APPLY_CHANGED); + assert(member_sync_author_key(&target, "1001", author.node_id, key) == MS_AUTHOR_PENDING); + root(&target, after); assert(memcmp(before, after, 32)); + member_sync_destroy(&target); assert(member_sync_init(&target) == 0 && chat_feedback_init(&target) == 0); + root(&target, after); uint8_t pending_root[32]; memcpy(pending_root, after, 32); + newer = original[0]; newer.ver++; sign_record(&author, 1001, &newer); + assert(chat_feedback_apply(&target, "1001", &newer) == 0); assert_stored(&target, &original[0]); + saved_events = events; + add_member(&target, &inviter, "1002"); + assert(member_sync_author_key(&target, "1001", author.node_id, key) == MS_AUTHOR_ACTIVE && events > saved_events); + root(&target, after); assert(!memcmp(before, after, 32) && memcmp(pending_root, after, 32)); + for (unsigned i = 0; i < 3; i++) assert_stored(&target, &original[i]); + puts("PASS: pending invitation survives restart; signer delivered only through another channel reactivates unchanged feedback"); + + /* Отказ чтения не становится штатным пропуском автора. */ + memset(key, 0xa5, sizeof(key)); + assert(sqlite3_set_authorizer(target.topo_sqlite_db, deny_node_keys, NULL) == SQLITE_OK); + assert(member_sync_author_key(&target, "1001", author.node_id, key) == MS_AUTHOR_ERROR); + for (unsigned i = 0; i < sizeof(key); i++) assert(key[i] == 0xa5); + assert(chat_feedback_apply(&target, "1001", &newer) < 0); + assert(sqlite3_set_authorizer(target.topo_sqlite_db, NULL, NULL) == SQLITE_OK); + root(&target, after); assert(!memcmp(before, after, 32)); + uint8_t member_before[32], member_after[32]; + assert(merkle_sync_read_hash(&target, MT_DATA_MEMBERS, "1001", 0, 0, member_before) == 0); + assert(sqlite3_exec(target.topo_sqlite_db, "CREATE TRIGGER fail_feedback_delete BEFORE DELETE ON merkle_tree_hash" + " WHEN OLD.namespace='1:1001' BEGIN SELECT RAISE(ABORT,'injected feedback index failure'); END", NULL, NULL, NULL) == SQLITE_OK); + saved_events = events; + assert(set_deleted(&target, &author, 1, 1) < 0 && events == saved_events); + assert(sqlite3_get_autocommit(target.topo_sqlite_db)); + assert(member_sync_author_key(&target, "1001", author.node_id, key) == MS_AUTHOR_ACTIVE); + root(&target, after); assert(!memcmp(before, after, 32)); + assert(merkle_sync_read_hash(&target, MT_DATA_MEMBERS, "1001", 0, 0, member_after) == 0 && !memcmp(member_before, member_after, 32)); + assert(sqlite3_exec(target.topo_sqlite_db, "DROP TRIGGER fail_feedback_delete", NULL, NULL, NULL) == SQLITE_OK); + assert(set_deleted(&target, &author, 1, 1) & MS_APPLY_CHANGED); + root(&target, before); saved_events = events; int vetoes = 1; + sqlite3_commit_hook(target.topo_sqlite_db, veto_commit, &vetoes); + assert(set_deleted(&target, &author, 2, 0) < 0 && events == saved_events); + sqlite3_commit_hook(target.topo_sqlite_db, NULL, NULL); + assert(sqlite3_get_autocommit(target.topo_sqlite_db)); + assert(member_sync_author_key(&target, "1001", author.node_id, key) == MS_AUTHOR_INACTIVE); + root(&target, after); assert(!memcmp(before, after, 32)); + assert(set_deleted(&target, &author, 2, 0) & MS_APPLY_CHANGED); + root(&target, before); saved_events = events; + assert(sqlite3_exec(target.topo_sqlite_db, "BEGIN IMMEDIATE", NULL, NULL, NULL) == SQLITE_OK); + assert(set_deleted(&target, &author, 3, 1) & MS_APPLY_CHANGED); + assert(events == saved_events); + assert(sqlite3_exec(target.topo_sqlite_db, "ROLLBACK", NULL, NULL, NULL) == SQLITE_OK); + root(&target, after); assert(!memcmp(before, after, 32) && events == saved_events); + assert(sqlite3_exec(target.topo_sqlite_db, "BEGIN IMMEDIATE", NULL, NULL, NULL) == SQLITE_OK); + assert(set_deleted(&target, &author, 3, 1) & MS_APPLY_CHANGED); + assert(sqlite3_exec(target.topo_sqlite_db, "COMMIT", NULL, NULL, NULL) == SQLITE_OK && events == saved_events); + member_sync_record_committed(&target, "1001", author.node_id); assert(events > saved_events); + for (unsigned i = 0; i < 3; i++) assert_stored(&target, &original[i]); + puts("PASS: SQL lookup/index failures, commit veto and outer rollback publish no partial data, roots or events"); + member_sync_destroy(&author); member_sync_destroy(&target); member_sync_destroy(&inviter); + assert(sqlite3_close(author.topo_sqlite_db) == SQLITE_OK && sqlite3_close(target.topo_sqlite_db) == SQLITE_OK); + assert(sqlite3_close(inviter.topo_sqlite_db) == SQLITE_OK); +} + /* Диагностика копии профиля: без изменения записей или доступа к приватным ключам. */ static int check_profile_authors(const char* path) { struct UTUN_INSTANCE inst = {0}; @@ -199,6 +363,7 @@ int main(int argc, char** argv) { assert(merkle_sync_read_hash(&target, MT_DATA_FEEDBACK, "1001", 0, 0, received) == 0); uint8_t empty[32] = {0}; assert(!memcmp(received, empty, 32)); puts("PASS: deleting a channel removes its feedback table and derived root"); + membership_regressions(ua); member_sync_destroy(&source); member_sync_destroy(&target); assert(sqlite3_close(source.topo_sqlite_db) == SQLITE_OK && sqlite3_close(target.topo_sqlite_db) == SQLITE_OK); uasync_poll(ua, 0); uasync_destroy(ua, 0); diff --git a/tests/test_member_purge.c b/tests/test_member_purge.c index 0cd61851..2adca819 100644 --- a/tests/test_member_purge.c +++ b/tests/test_member_purge.c @@ -311,11 +311,40 @@ static void test_purge_invalid_records(struct UASYNC* ua) { DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "PASS purge: invalid join/update/owner/invitation and malformed key removed with empty root"); } +/* Отказ любой стадии crypto/SQL не становится INACTIVE/PENDING и не меняет выходной ключ. */ +static void test_author_errors(struct UASYNC* ua) { + struct purge_fixture f; purge_setup(&f, ua, 1); uint8_t key[32]; + assert(member_sync_author_key(&f.inst, "1001", f.member.node_id, key) == MS_AUTHOR_ACTIVE); + for (int kind = FAULT_KEY; kind <= FAULT_ID; kind++) { + int stages = kind == FAULT_ID ? 1 : 2; + for (int at = 1; at <= stages; at++) { + memset(key, 0xa5, sizeof(key)); crypto_fault = kind; crypto_at = at; crypto_calls = 0; + assert(member_sync_author_key(&f.inst, "1001", f.member.node_id, key) == MS_AUTHOR_ERROR); + assert(crypto_calls >= at); reset_faults(); + for (unsigned i = 0; i < sizeof(key); i++) assert(key[i] == 0xa5); + } + } + for (unsigned kind = 0; kind < 4; kind++) { + if (kind == 0) prepare_fault = "FROM peers_1001 WHERE node_id="; + if (kind == 1) bind_fault = "FROM peers_1001 WHERE node_id="; + if (kind == 2) step_fault = "FROM peers_1001 WHERE node_id="; + if (kind == 3) finalize_fault = "FROM peers_1001 WHERE node_id="; + memset(key, 0xa5, sizeof(key)); + assert(member_sync_author_key(&f.inst, "1001", f.member.node_id, key) == MS_AUTHOR_ERROR); + reset_faults(); + for (unsigned i = 0; i < sizeof(key); i++) assert(key[i] == 0xa5); + } + assert(member_sync_author_key(&f.inst, "1001", f.member.node_id, key) == MS_AUTHOR_ACTIVE); + purge_destroy(&f); + DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "PASS author: crypto and SQL failures remain operational errors with untouched output key"); +} + void test_member_purge(struct UASYNC* ua) { size_t timers = ua->timer_alloc_count - ua->timer_free_count; reset_faults(); debug_set_category_level(DEBUG_CATEGORY_DEBUG, DEBUG_LEVEL_INFO); test_purge_transactions(ua); test_purge_crypto(ua); test_purge_dependencies(ua); test_purge_init_and_growth(ua); test_purge_start(ua); test_purge_invalid_records(ua); + test_author_errors(ua); uasync_drain_immediate(ua); assert(ua->timer_alloc_count - ua->timer_free_count == timers); DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "ALL PASS: atomic purge contracts; timer owners released; full allocation check follows uasync destroy");