From 5b27bb0950fd03d29c223b5960faff89baf37970 Mon Sep 17 00:00:00 2001 From: Anders Betts Date: Sun, 20 Sep 2026 09:20:39 +0200 Subject: audit: verify voucher chains and attachment hashes --- tests/test_core.c | 140 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) (limited to 'tests') diff --git a/tests/test_core.c b/tests/test_core.c index 6cd7b18..885523c 100644 --- a/tests/test_core.c +++ b/tests/test_core.c @@ -635,6 +635,9 @@ int main(void) CHECK_OK(d); CHECK(jbool(d, "result.ok")); CHECK(jint(d, "result.checked") > 5); + CHECK(jint(d, "result.vouchers_checked") == 0); + CHECK(jint(d, "result.unbalanced_vouchers") == 0); + CHECK(jint(d, "result.first_bad_voucher_id") == -1); yyjson_doc_free(d); d = call(reqf("{\"v\":1,\"id\":\"27\",\"cmd\":\"audit.list\"," @@ -1890,6 +1893,143 @@ int main(void) CHECK_OK(d); CHECK(jbool(d, "result.ok")); CHECK(jint(d, "result.checked") > 20); + CHECK(jint(d, "result.vouchers_checked") > 10); + CHECK(jint(d, "result.unbalanced_vouchers") == 0); + yyjson_doc_free(d); + + /* ------------------- audit.verify tamper detection ---------------- */ + + d = call(reqf("{\"v\":1,\"id\":\"80\",\"cmd\":\"audit.verify\"," + "\"session\":\"%s\",\"args\":{\"full\":true}}", + g_session)); + CHECK_OK(d); + CHECK(jbool(d, "result.ok")); + CHECK(jint(d, "result.vouchers_checked") > 10); + CHECK(jint(d, "result.attachments_checked") >= 1); + CHECK(jint(d, "result.unbalanced_vouchers") == 0); + yyjson_doc_free(d); + + int64_t voucher_id = 0; + char voucher_desc[256] = ""; + sqlite3_stmt *vst = NULL; + CHECK(sqlite3_prepare_v2(g_db, + "SELECT id,description FROM vouchers" + " ORDER BY id LIMIT 1", + -1, &vst, NULL) == SQLITE_OK); + if (sqlite3_step(vst) == SQLITE_ROW) { + voucher_id = sqlite3_column_int64(vst, 0); + snprintf(voucher_desc, sizeof voucher_desc, "%s", + (const char *)sqlite3_column_text(vst, 1)); + } + sqlite3_finalize(vst); + CHECK(voucher_id > 0); + + int64_t attachment_id = 0; + sqlite3_stmt *ast = NULL; + CHECK(sqlite3_prepare_v2(g_db, + "SELECT id FROM attachments ORDER BY id LIMIT 1", + -1, &ast, NULL) == SQLITE_OK); + if (sqlite3_step(ast) == SQLITE_ROW) + attachment_id = sqlite3_column_int64(ast, 0); + sqlite3_finalize(ast); + CHECK(attachment_id > 0); + + /* attachments are re-hashed only with full:true */ + err = NULL; + CHECK(db_exec(g_db, reqf("UPDATE attachments SET content=x'00'" + " WHERE id=%lld", (long long)attachment_id), + &err) == 0); + free(err); + err = NULL; + + d = call(reqf("{\"v\":1,\"id\":\"81\",\"cmd\":\"audit.verify\"," + "\"session\":\"%s\"}", + g_session)); + CHECK_OK(d); + CHECK(jbool(d, "result.ok")); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"82\",\"cmd\":\"audit.verify\"," + "\"session\":\"%s\",\"args\":{\"full\":true}}", + g_session)); + CHECK_OK(d); + CHECK(!jbool(d, "result.ok")); + CHECK(jint(d, "result.first_bad_attachment_id") == attachment_id); + CHECK(jint(d, "result.vouchers_checked") > 10); + yyjson_doc_free(d); + + /* the append-only trigger blocks the tamper; after dropping it the + modified description must break the chain at that voucher */ + err = NULL; + CHECK(db_exec(g_db, reqf("UPDATE vouchers SET description='x'" + " WHERE id=%lld", (long long)voucher_id), + &err) != 0); + free(err); + err = NULL; + CHECK(db_exec(g_db, "DROP TRIGGER vouchers_no_update", &err) == 0); + free(err); + err = NULL; + CHECK(db_exec(g_db, reqf("UPDATE vouchers SET description='kapad'" + " WHERE id=%lld", (long long)voucher_id), + &err) == 0); + free(err); + err = NULL; + + d = call(reqf("{\"v\":1,\"id\":\"83\",\"cmd\":\"audit.verify\"," + "\"session\":\"%s\"}", + g_session)); + CHECK_OK(d); + CHECK(!jbool(d, "result.ok")); + CHECK(jint(d, "result.first_bad_voucher_id") == voucher_id); + CHECK(jint(d, "result.first_bad_seq") == -1); + yyjson_doc_free(d); + + char *fix = sqlite3_mprintf("UPDATE vouchers SET description=%Q" + " WHERE id=%lld", voucher_desc, + (long long)voucher_id); + CHECK(fix != NULL); + err = NULL; + CHECK(db_exec(g_db, fix, &err) == 0); + sqlite3_free(fix); + free(err); + err = NULL; + CHECK(db_exec(g_db, "CREATE TRIGGER vouchers_no_update BEFORE UPDATE ON" + " vouchers BEGIN SELECT RAISE(ABORT," + " 'vouchers are append-only'); END", + &err) == 0); + free(err); + err = NULL; + + d = call(reqf("{\"v\":1,\"id\":\"84\",\"cmd\":\"audit.verify\"," + "\"session\":\"%s\"}", + g_session)); + CHECK_OK(d); + CHECK(jbool(d, "result.ok")); + CHECK(jint(d, "result.first_bad_voucher_id") == -1); + yyjson_doc_free(d); + + /* the balance backstop catches a row appended outside the ledger path */ + err = NULL; + CHECK(db_exec(g_db, + reqf("INSERT INTO voucher_rows(org_id,voucher_id,line_no," + "account_id,debit_ore,credit_ore,description)" + " SELECT org_id,id,99," + "(SELECT id FROM accounts WHERE org_id=%d" + " AND number='3001'),1,0,NULL" + " FROM vouchers WHERE id=%lld", + (int)org_id, (long long)voucher_id), + &err) == 0); + free(err); + err = NULL; + + d = call(reqf("{\"v\":1,\"id\":\"85\",\"cmd\":\"audit.verify\"," + "\"session\":\"%s\"}", + g_session)); + CHECK_OK(d); + CHECK(!jbool(d, "result.ok")); + CHECK(jint(d, "result.first_bad_voucher_id") == voucher_id); + CHECK(jint(d, "result.first_unbalanced_voucher_id") == voucher_id); + CHECK(jint(d, "result.unbalanced_vouchers") == 1); yyjson_doc_free(d); test_transport(); -- cgit v1.3