diff options
| author | Anders Betts <anders.betts@gmail.com> | 2026-09-20 09:20:39 +0200 |
|---|---|---|
| committer | Anders Betts <anders.betts@gmail.com> | 2026-09-20 09:20:39 +0200 |
| commit | 5b27bb0950fd03d29c223b5960faff89baf37970 (patch) | |
| tree | 5cc99420a4fc1365fdb8bf4bbec8237fa306fd4b | |
| parent | a61cfeefb0599e74fc9189b189b16dd0b6d36f0f (diff) | |
| download | bokf-5b27bb0950fd03d29c223b5960faff89baf37970.tar.gz bokf-5b27bb0950fd03d29c223b5960faff89baf37970.zip | |
audit: verify voucher chains and attachment hashes
| -rw-r--r-- | clients/bokftui.c | 70 | ||||
| -rw-r--r-- | docs/PROTOCOL.md | 5 | ||||
| -rw-r--r-- | docs/STATE.md | 69 | ||||
| -rw-r--r-- | src/commands.c | 114 | ||||
| -rw-r--r-- | src/ledger.c | 171 | ||||
| -rw-r--r-- | src/ledger.h | 11 | ||||
| -rw-r--r-- | tests/test_core.c | 140 |
7 files changed, 546 insertions, 34 deletions
diff --git a/clients/bokftui.c b/clients/bokftui.c index f7500bf..7c31ccb 100644 --- a/clients/bokftui.c +++ b/clients/bokftui.c @@ -3307,31 +3307,73 @@ static void inbox_screen(struct app *a) } } +static void audit_verify_run(struct app *a, int full) +{ + char *resp = client_rpc(&a->conn, "audit.verify", a->session, a->org, + full ? "{\"full\":true}" : "{}"); + if (!resp || !client_ok(resp)) { + show_error("Revision", resp); + free(resp); + return; + } + int64_t checked = jint_val(resp, "result.checked", 0); + int64_t vouchers = jint_val(resp, "result.vouchers_checked", 0); + int is_ok = jbool_val(resp, "result.ok", 0); + if (is_ok) { + if (full) + tui_message("Hashkedjor", + "Kedjorna är intakta.\n%lld verifikat och %lld " + "händelser kontrollerade.\n%lld underlag " + "hash-kontrollerade.", + (long long)vouchers, (long long)checked, + (long long)jint_val(resp, "result.attachments_checked", + 0)); + else + tui_message("Hashkedjor", + "Kedjorna är intakta.\n%lld verifikat och %lld " + "händelser kontrollerade.", + (long long)vouchers, (long long)checked); + } else { + int64_t bad_v = jint_val(resp, "result.first_bad_voucher_id", 0); + int64_t bad_a = jint_val(resp, "result.first_bad_seq", 0); + int64_t unbal = jint_val(resp, "result.first_unbalanced_voucher_id", 0); + int64_t bad_att = + jint_val(resp, "result.first_bad_attachment_id", 0); + char detail[320] = ""; + if (bad_v) + snprintf(detail + strlen(detail), sizeof detail - strlen(detail), + "Första trasiga verifikat: %lld\n", (long long)bad_v); + if (bad_a) + snprintf(detail + strlen(detail), sizeof detail - strlen(detail), + "Första trasiga händelse: %lld\n", (long long)bad_a); + if (unbal) + snprintf(detail + strlen(detail), sizeof detail - strlen(detail), + "Första obalanserade verifikat: %lld\n", + (long long)unbal); + if (bad_att) + snprintf(detail + strlen(detail), sizeof detail - strlen(detail), + "Första trasiga underlag: %lld\n", (long long)bad_att); + tui_message("Hashkedjor", "KEDJORNA ÄR BRUTNA!\n%s", detail); + } + free(resp); +} + static void audit_screen(struct app *a) { static const char *const audit_items[] = { "Verifiera hashkedjan", + "Verifiera allt (inkl. underlag)", "Senaste händelser" }; static int last_sel = 0; for (;;) { if (g_quit) return; - int sel = tui_menu("Revision", audit_items, 2, 0, &last_sel); + int sel = tui_menu("Revision", audit_items, 3, 0, &last_sel); if (sel < 0) return; if (sel == 0) { - char *resp = - client_rpc(&a->conn, "audit.verify", a->session, a->org, "{}"); - if (!resp || !client_ok(resp)) { - show_error("Revision", resp); - free(resp); - continue; - } - int64_t checked = jint_val(resp, "result.checked", 0); - int is_ok = jbool_val(resp, "result.ok", 0); - tui_message("Hashkedja", "%s\n%d händelser kontrollerade.", - is_ok ? "Kedjan är intakt." : "KEDJAN ÄR BRUTEN!", - (int)checked); - free(resp); + audit_verify_run(a, 0); + } else if (sel == 1) { + audit_verify_run(a, 1); } else { for (;;) { char *resp = client_rpc(&a->conn, "audit.list", a->session, diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 7052b83..47f6f26 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -443,10 +443,11 @@ be created. |---|---|---| | `backup.snapshot` | `dest?` | `path`, `sha256`, `size`, `at` — uses SQLite `VACUUM INTO`, no downtime | | `audit.list` | `from?`, `to?`, `action?`, `actor?`, `limit`, `cursor` | behandlingshistorik | -| `audit.verify` | `full?` | recomputes voucher and audit hash chains; `ok`, `checked`, first/last mismatch if any | +| `audit.verify` | `full?` | recomputes the voucher and audit hash chains, flags unbalanced vouchers, and with `full:true` re-hashes attachments; `ok`, `checked` (audit entries), `vouchers_checked`, `attachments_checked`, `unbalanced_vouchers` and the first bad `first_bad_voucher_id` / `first_bad_seq` / `first_bad_attachment_id` / `first_unbalanced_voucher_id` on failure | `audit.verify` is cheap enough to run after every import and before every -backup; `full:true` includes attachment hashes. +backup; `full:true` includes attachment hashes. The voucher chain is verified +per org in posting order (SCHEMA.md §7.1); the audit chain globally. ## 8. The TUI is just a client diff --git a/docs/STATE.md b/docs/STATE.md index d75f3cb..9f2f29f 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -74,11 +74,14 @@ server/protocol/ledger only. cert reload on file change) with client targets `tls:host:port` and system-trust verification (`BOKFD_TLS_CA` for private CAs). Certificates come from a lego sidecar using INWX DNS-01 (`compose.yaml`). Externals - get accounts/roles/tokens, never VPN access. `scripts/deploy.sh` builds - locally and ships over SSH, or builds on the host when architectures - differ. `scripts/deploy.sh --dev` cross-compiles the binaries here and - hot-reloads the daemon (SIGHUP re-exec via `docker cp`), skipping the - image build and container recreate. + get accounts/roles/tokens, never VPN access. The runtime image is + **Alpine + backend only** (`bokfd`, `bokfctl`; the ncurses TUI is a + frontend built on the client machine). `scripts/deploy.sh` builds locally + and ships over SSH, or builds the same image natively on the host when + architectures differ (no cross-compilation — a musl cross toolchain is + not trusted yet). `scripts/deploy.sh --dev` hot-reloads the binaries in + the running container (SIGHUP re-exec via `docker cp`) when the + architecture matches, otherwise it falls back to a full remote build. 15. **Reports in the TUI**: rendered as fixed-width Swedish tables that mirror the Kapitas PDF exports (Saldobalans, Resultatrapport with previous-year column and 89xx bokfört/ej bokfört, Balansrapport with Ing balans/Ing @@ -152,8 +155,15 @@ server/protocol/ledger only. years are flagged in the document. ~~SRU files (INK2/INK2R/INK2S)~~ done as `sru.export` (official 2025P4 field tables, BAS mapping, TUI save in Rapporter -> Inkomstdeklaration). -4. `audit.verify` must also verify the **voucher** hash chain (today only the - audit chain is verified). +4. ~~`audit.verify` must also verify the **voucher** hash chain (today only the + audit chain is verified).~~ Done: recomputes every org's voucher chain in + posting order (SCHEMA.md §7.1), flags unbalanced vouchers as a backstop, + and `full:true` re-hashes attachment content; result carries + `vouchers_checked`, `unbalanced_vouchers`, `attachments_checked` and the + first bad voucher/audit/attachment id. TUI Revision shows both counts and + the bad ids. **Found while testing: `attachments` has no + append-only triggers** (COMPLIANCE.md §2 claims it does); content changes + are detected only by `audit.verify full:true`. 5. ~~`report.general_ledger` and `report.voucher_list`~~ implemented (Huvudbok, Verifikationslista) with Kapitas-style TUI tables; the ledger API supports `accounts`/`from`/`to`, the list an optional `series`. @@ -182,6 +192,21 @@ server/protocol/ledger only. ## Environment / how to run +- **Deployed**: `scripts/deploy.sh` (latest `v0.1.44`, healthy on nas). + Live daemon `tls:bokf.makandra.eu:8788`, token + `~/.config/bokf/migration-token` (scopes `read,write`; owner-only actions + like closing years must be done by the human in the TUI). Git remote + `origin` is `nas:/mnt/data/git-repos/bokf.git` (push `main` and tags). +- **Local test rig** (transient, `/tmp`): daemon + `./build/bokfd --db /tmp/opencode/bokf-local/t.db --socket + /tmp/opencode/bokf-local/sock`, org 1, login `admin`/`testpass123`. + Drive the TUI over a pty with `scripts/tui-sandbox.sh -- ./build/bokftui + --socket /tmp/opencode/bokf-local/sock --org 1 --fy 1 ...` plus a small + driver that feeds keys and an ANSI renderer (recreate if gone; arrows are + `ESC O B/A`, Tab `\t`, `^X` `\x18`, `^Enter` `ESC[27;5;13~`, F5 + `ESC[15~`). Never test against the live daemon. + + - Demo: db `~/bokf-demo/bokfd.db`, socket `~/bokf-demo/bokfd.sock`, pid file `~/bokf-demo/bokfd.pid`; login `admin` / `demo1234`. Start TUI: `cd ~/work/bokf && BOKFD_SOCKET=$HOME/bokf-demo/bokfd.sock \ @@ -207,4 +232,32 @@ server/protocol/ledger only. - Never commit unless the human asks. - SQLite files must not be backed up live with restic; use `backup.snapshot` (`VACUUM INTO`) and point restic at the snapshots. -- Schema version is 3; forward migrations are in `db.c`. +- Schema version is 6 (v3 moms rules; v4/v6 year info; v5 org + description/shares + board members); forward migrations are in `db.c`. + +## Makandra driftstatus (org 2) + +- **Org**: Makandra AB, org 2. Räkenskapsår (id): 2022=3, 2023=4, 2024=5, + 2025=6, 2026=7, **2027=2 (öppet, aktuellt)**. Bokslut/AR/deklaration görs + för det år som är valt i sessionen. +- **FK2027**: importerade Kapitas-böcker + 28 bokförda verifikat (V21–V48) + för bank/skatt maj–sep 2026, samt V49 som makulerar en dubblett (V20). + 1930 stämmer mot banken utom **CDON 2 409 kr** (väntar på kvittots del + 2–4; bokförs när det kommer). 1630 = 40 721 (exakt enligt Skatteverket). +- **Underlag**: 279 attachment i org 2 (alla historikdokument + insamlade + underlag). Bank-/SKV-utdrag ligger i `~/Makandra AB/{bank,skatteverket}` + (Syncthing), källkorpus i `~/Downloads/Makandra AB-…/Bokföring/`. +- **Stängning**: 2022–2026 ska stängas av ägaren via **Räkenskapsår** i + TUI:n; låt FK2027 vara öppen till nästa bokslut. +- **Deklaration**: FK2026 är deklarerad av revisorn. FK2027 deklareras + våren 2027 (INK2/SRU via Bokslutshubben → Inkomstdeklaration). +- **Årshäftet**: fylls i Bokslutshubben (händelser, stämma, utdelning + + datum, medelantal, noter). OBS: `dividend_ore` för FK2027 kan vara ett + testvärde (10 000) — kontrollera före AR/deklaration. +- **Beslut/regler från bokföringsarbetet**: inga bokföringar utan + godkännande; låsta år förblir låsta (rättelser görs i aktuellt år); SIE- + importören ändras inte och importerad data "manipuleras" inte; + historikårens P&L nettar noll pga källsystemets stängningar (AR hoppar + över "Stäng"-verifikat i flerårsöversikten); utdelning bokförs vid stämman + med mallen **Utdelning** (D 2099/K 2898); pappersoriginal finns i fysisk + pärm (får refereras i efterhand, även i stängda år). diff --git a/src/commands.c b/src/commands.c index 448d03e..d21d4c8 100644 --- a/src/commands.c +++ b/src/commands.c @@ -1545,25 +1545,119 @@ static yyjson_mut_val *h_audit_list(struct req *r) return out; } +static int verify_attachments(sqlite3 *db, int64_t *checked, int64_t *bad_id, + char **err) +{ + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(db, + "SELECT id,sha256,size_bytes,content FROM attachments" + " ORDER BY id", + -1, &st, NULL) != SQLITE_OK) { + if (err && !*err) + *err = xstrdup(sqlite3_errmsg(db)); + return -1; + } + int ret = 0; + for (;;) { + int step = sqlite3_step(st); + if (step == SQLITE_DONE) + break; + if (step != SQLITE_ROW) { + if (err && !*err) + *err = xstrdup(sqlite3_errmsg(db)); + ret = -1; + break; + } + int64_t id = sqlite3_column_int64(st, 0); + const void *sha = sqlite3_column_blob(st, 1); + int sha_n = sqlite3_column_bytes(st, 1); + int64_t size = sqlite3_column_int64(st, 2); + const void *content = sqlite3_column_blob(st, 3); + int content_n = sqlite3_column_bytes(st, 3); + unsigned char digest[32]; + util_sha256(content, (size_t)content_n, digest); + (*checked)++; + if (sha_n != 32 || memcmp(sha, digest, 32) != 0 || + size != content_n) { + if (!*bad_id) + *bad_id = id; + ret = 1; + } + } + sqlite3_finalize(st); + return ret; +} + static yyjson_mut_val *h_audit_verify(struct req *r) { - int64_t checked = 0, bad_seq = 0; + int full = 0; + arg_bool(r->args, "full", &full); + + int64_t audit_checked = 0, audit_bad = 0; char *err = NULL; - int rc = audit_verify(r->db, &checked, &bad_seq, &err); - if (rc < 0) { + int audit_rc = audit_verify(r->db, &audit_checked, &audit_bad, &err); + if (audit_rc < 0) { + yyjson_mut_val *res = fail(r, "INTERNAL", err ? err : "verify failed"); + free(err); + return res; + } + free(err); + err = NULL; + + struct ledger_verify_result lv; + int ledger_rc = ledger_verify(r->db, &lv, &err); + if (ledger_rc < 0) { yyjson_mut_val *res = fail(r, "INTERNAL", err ? err : "verify failed"); free(err); return res; } free(err); + err = NULL; + + int64_t att_checked = 0, att_bad = 0; + int att_rc = 0; + if (full) { + att_rc = verify_attachments(r->db, &att_checked, &att_bad, &err); + if (att_rc < 0) { + yyjson_mut_val *res = + fail(r, "INTERNAL", err ? err : "verify failed"); + free(err); + return res; + } + free(err); + } + + int ok = audit_rc == 0 && ledger_rc == 0 && att_rc == 0; yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); - yyjson_mut_obj_add_bool(r->rdoc, o, "ok", rc == 0); - yyjson_mut_obj_add_strcpy(r->rdoc, o, "kind", "audit"); - yyjson_mut_obj_add_int(r->rdoc, o, "checked", checked); - if (rc == 1) - yyjson_mut_obj_add_int(r->rdoc, o, "first_bad_seq", bad_seq); + yyjson_mut_obj_add_bool(r->rdoc, o, "ok", ok); + yyjson_mut_obj_add_int(r->rdoc, o, "checked", audit_checked); + yyjson_mut_obj_add_int(r->rdoc, o, "vouchers_checked", + lv.vouchers_checked); + yyjson_mut_obj_add_int(r->rdoc, o, "unbalanced_vouchers", + lv.unbalanced_vouchers); + if (audit_bad) + yyjson_mut_obj_add_int(r->rdoc, o, "first_bad_seq", audit_bad); else yyjson_mut_obj_add_null(r->rdoc, o, "first_bad_seq"); + if (lv.first_bad_voucher_id) + yyjson_mut_obj_add_int(r->rdoc, o, "first_bad_voucher_id", + lv.first_bad_voucher_id); + else + yyjson_mut_obj_add_null(r->rdoc, o, "first_bad_voucher_id"); + if (lv.first_unbalanced_voucher_id) + yyjson_mut_obj_add_int(r->rdoc, o, "first_unbalanced_voucher_id", + lv.first_unbalanced_voucher_id); + else + yyjson_mut_obj_add_null(r->rdoc, o, "first_unbalanced_voucher_id"); + if (full) { + yyjson_mut_obj_add_int(r->rdoc, o, "attachments_checked", + att_checked); + if (att_bad) + yyjson_mut_obj_add_int(r->rdoc, o, "first_bad_attachment_id", + att_bad); + else + yyjson_mut_obj_add_null(r->rdoc, o, "first_bad_attachment_id"); + } return o; } @@ -4438,8 +4532,8 @@ const struct command g_commands[] = { h_agent_instructions }, { "audit.list", "Read the audit log for the active org", PERM_READ, 1, 0, 0, h_audit_list }, - { "audit.verify", "Verify the audit hash chain", PERM_READ, 0, 0, 0, - h_audit_verify }, + { "audit.verify", "Verify the voucher and audit hash chains", PERM_READ, 0, + 0, 0, h_audit_verify }, { "backup.snapshot", "Create a consistent database snapshot", PERM_ADMIN, 0, 1, 0, h_backup_snapshot }, { "account.list", "List the chart of accounts", PERM_READ, 1, 0, 0, diff --git a/src/ledger.c b/src/ledger.c index 72402f0..4dacde4 100644 --- a/src/ledger.c +++ b/src/ledger.c @@ -66,6 +66,177 @@ void ledger_voucher_hash(const unsigned char prev[32], int64_t org_id, buf_free(&b); } +static void verify_err(char **err, const char *msg) +{ + if (err && !*err) + *err = xstrdup(msg); +} + +static char *copy_text(const unsigned char *p) +{ + return xstrdup(p ? (const char *)p : ""); +} + +static void free_rows(struct ledger_row *rows, size_t nrows) +{ + for (size_t i = 0; i < nrows; i++) { + free((char *)rows[i].account); + free((char *)rows[i].description); + } + free(rows); +} + +static int verify_org(sqlite3 *db, int64_t org_id, + struct ledger_verify_result *out, char **err) +{ + sqlite3_stmt *vs = NULL, *rs = NULL; + if (sqlite3_prepare_v2( + db, + "SELECT v.id,v.hash_prev,v.hash,fy.label,v.series,v.number,v.date," + "v.description FROM vouchers v JOIN fiscal_years fy" + " ON fy.org_id=v.org_id AND fy.id=v.fiscal_year_id" + " WHERE v.org_id=?1 ORDER BY v.id", + -1, &vs, NULL) != SQLITE_OK) { + verify_err(err, sqlite3_errmsg(db)); + return -1; + } + if (sqlite3_prepare_v2( + db, + "SELECT a.number,r.debit_ore,r.credit_ore,r.description" + " FROM voucher_rows r JOIN accounts a" + " ON a.org_id=r.org_id AND a.id=r.account_id" + " WHERE r.org_id=?1 AND r.voucher_id=?2 ORDER BY r.line_no", + -1, &rs, NULL) != SQLITE_OK) { + sqlite3_finalize(vs); + verify_err(err, sqlite3_errmsg(db)); + return -1; + } + + unsigned char running[32]; + memset(running, 0, sizeof running); + int ret = 0; + sqlite3_bind_int64(vs, 1, org_id); + for (;;) { + int step = sqlite3_step(vs); + if (step == SQLITE_DONE) + break; + if (step != SQLITE_ROW) { + verify_err(err, sqlite3_errmsg(db)); + ret = -1; + break; + } + int64_t id = sqlite3_column_int64(vs, 0); + const void *hprev = sqlite3_column_blob(vs, 1); + int hprev_n = sqlite3_column_bytes(vs, 1); + const void *hstored = sqlite3_column_blob(vs, 2); + int hstored_n = sqlite3_column_bytes(vs, 2); + char *label = copy_text(sqlite3_column_text(vs, 3)); + char *series = copy_text(sqlite3_column_text(vs, 4)); + int64_t number = sqlite3_column_int64(vs, 5); + char *date = copy_text(sqlite3_column_text(vs, 6)); + char *vdesc = copy_text(sqlite3_column_text(vs, 7)); + + struct ledger_row *rows = NULL; + size_t nrows = 0, cap = 0; + int64_t sum_debit = 0, sum_credit = 0; + sqlite3_bind_int64(rs, 1, org_id); + sqlite3_bind_int64(rs, 2, id); + int rstep; + while ((rstep = sqlite3_step(rs)) == SQLITE_ROW) { + if (nrows == cap) { + cap = cap ? cap * 2 : 8; + rows = xrealloc(rows, cap * sizeof *rows); + } + rows[nrows].account = copy_text(sqlite3_column_text(rs, 0)); + rows[nrows].debit_ore = sqlite3_column_int64(rs, 1); + rows[nrows].credit_ore = sqlite3_column_int64(rs, 2); + rows[nrows].description = copy_text(sqlite3_column_text(rs, 3)); + sum_debit += rows[nrows].debit_ore; + sum_credit += rows[nrows].credit_ore; + nrows++; + } + sqlite3_reset(rs); + sqlite3_clear_bindings(rs); + if (rstep != SQLITE_DONE) { + verify_err(err, sqlite3_errmsg(db)); + free_rows(rows, nrows); + free(label); + free(series); + free(date); + free(vdesc); + ret = -1; + break; + } + + unsigned char expect[32]; + ledger_voucher_hash(running, org_id, label, series, number, date, vdesc, + rows, nrows, expect); + out->vouchers_checked++; + if (sum_debit != sum_credit) { + out->unbalanced_vouchers++; + if (!out->first_unbalanced_voucher_id) + out->first_unbalanced_voucher_id = id; + if (ret == 0) + ret = 2; + } + if (hprev_n != 32 || hstored_n != 32 || + memcmp(hprev, running, 32) != 0 || + memcmp(hstored, expect, 32) != 0) { + out->first_bad_voucher_id = id; + ret = 1; + } else { + memcpy(running, expect, 32); + } + free_rows(rows, nrows); + free(label); + free(series); + free(date); + free(vdesc); + if (ret == 1) + break; + } + sqlite3_finalize(vs); + sqlite3_finalize(rs); + return ret; +} + +int ledger_verify(sqlite3 *db, struct ledger_verify_result *out, char **err) +{ + memset(out, 0, sizeof *out); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(db, "SELECT DISTINCT org_id FROM vouchers" + " ORDER BY org_id", + -1, &st, NULL) != SQLITE_OK) { + verify_err(err, sqlite3_errmsg(db)); + return -1; + } + int rc = 0; + for (;;) { + int step = sqlite3_step(st); + if (step == SQLITE_DONE) + break; + if (step != SQLITE_ROW) { + verify_err(err, sqlite3_errmsg(db)); + rc = -1; + break; + } + int64_t org_id = sqlite3_column_int64(st, 0); + int r = verify_org(db, org_id, out, err); + if (r < 0) { + rc = -1; + break; + } + if (r == 1) { + rc = 1; + break; + } + if (r == 2) + rc = 1; + } + sqlite3_finalize(st); + return rc; +} + static int idempotent_lookup(sqlite3 *db, int64_t org_id, const char *client_ref, char **out_json) { diff --git a/src/ledger.h b/src/ledger.h index 30271d0..cf8f9b1 100644 --- a/src/ledger.h +++ b/src/ledger.h @@ -48,4 +48,15 @@ void ledger_voucher_hash(const unsigned char prev[32], int64_t org_id, const struct ledger_row *rows, size_t nrows, unsigned char out[32]); +struct ledger_verify_result { + int64_t vouchers_checked; + int64_t first_bad_voucher_id; /* 0 = none */ + int64_t unbalanced_vouchers; + int64_t first_unbalanced_voucher_id; /* 0 = none */ +}; + +/* Recomputes every org's voucher hash chain (SCHEMA.md 7.1) and scans for + unbalanced vouchers. 0 = intact, 1 = broken, -1 = database error. */ +int ledger_verify(sqlite3 *db, struct ledger_verify_result *out, char **err); + #endif 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(); |
