diff --git a/pkg/spec/test/folio-submit-balance-predicate.test.ts b/pkg/spec/test/folio-submit-balance-predicate.test.ts index 90c0885..1d27c20 100644 --- a/pkg/spec/test/folio-submit-balance-predicate.test.ts +++ b/pkg/spec/test/folio-submit-balance-predicate.test.ts @@ -241,3 +241,141 @@ test("route gate: home landing with double-insert delta fires", () => { false, ); }); + +// Precision. Cents are integers in float64 here, so the equality only means +// something while every number involved is exactly representable. The app takes +// any amount that fits a Kotlin Long, and an iOS run reached a balance around +// 1e18 cents, where representable values sit 128 apart: the delta of a +// perfectly healthy single submit no longer reads back as the typed amount. +const HUGE_BALANCE = 999999999999999900; + +test("above 2^53 the arithmetic itself is wrong, which is why the guard exists", () => { + assert.notEqual(Math.abs(HUGE_BALANCE + 1600 - HUGE_BALANCE), 1600); +}); + +test("above 2^53 a healthy single submit is not reported", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "home", + lastAction: { kind: "Tap", on: submitOn }, + typedAmount: 1600, + prevTotalBalance: HUGE_BALANCE, + currTotalBalance: HUGE_BALANCE + 1600, + }), + true, + ); +}); + +test("above 2^53 a double-submit delta is not reported either", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "home", + lastAction: { kind: "Tap", on: submitOn }, + typedAmount: 1600, + prevTotalBalance: HUGE_BALANCE, + currTotalBalance: HUGE_BALANCE + 3200, + }), + true, + ); +}); + +test("an unreadable previous balance above 2^53 is not evidence", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "home", + lastAction: { kind: "Tap", on: submitOn }, + typedAmount: 1600, + prevTotalBalance: HUGE_BALANCE, + currTotalBalance: 5000, + }), + true, + ); +}); + +// A typed amount past the safe range cannot be compared either. parseTypedAmount +// returns 0 for those now, but the predicate takes the number from its caller +// and must not convict on one it cannot hold. +test("typed amount above 2^53 is not evidence", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "home", + lastAction: { kind: "Tap", on: submitOn }, + typedAmount: 1e23, + prevTotalBalance: 0, + currTotalBalance: 0, + }), + true, + ); +}); + +// The boundary, from both sides. MAX_SAFE_INTEGER still gets judged; one cent +// more is where counting stops being exact. +test("boundary: a double submit landing exactly on MAX_SAFE_INTEGER still fires", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "home", + lastAction: { kind: "Tap", on: submitOn }, + typedAmount: 4503599627370495, + prevTotalBalance: 0, + currTotalBalance: 9007199254740990, + }), + false, + ); +}); + +test("boundary: a single submit landing exactly on MAX_SAFE_INTEGER passes", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "home", + lastAction: { kind: "Tap", on: submitOn }, + typedAmount: 9007199254740991, + prevTotalBalance: 0, + currTotalBalance: 9007199254740991, + }), + true, + ); +}); + +test("boundary: one cent past MAX_SAFE_INTEGER stops being evidence", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "home", + lastAction: { kind: "Tap", on: submitOn }, + typedAmount: 4503599627370496, + prevTotalBalance: 0, + currTotalBalance: 9007199254740992, + }), + true, + ); +}); + +// The guard covers the balances and the typed amount, not their difference: two +// safe balances subtract exactly whenever the result could have matched a safe +// typed amount, so a mismatch here is real and must still be reported. +test("a large but exact difference between safe balances still fires", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "home", + lastAction: { kind: "Tap", on: submitOn }, + typedAmount: 500, + prevTotalBalance: -9007199254740991, + currTotalBalance: 9007199254740991, + }), + false, + ); +}); + +// The 21-digit corpus amount end to end: the app refuses it, so nothing moves, +// and the property must stay quiet rather than demand a 1e23-cent move. +test("21-digit typed amount with an unmoved balance is not a violation", () => { + assert.equal( + submitChangesBalanceByTypedAmount({ + route: "home", + lastAction: { kind: "Tap", on: submitOn }, + typedAmount: parseTypedAmount("999999999999999999999"), + prevTotalBalance: 220900, + currTotalBalance: 220900, + }), + true, + ); +}); diff --git a/pkg/spec/test/folio-typed-amount.test.ts b/pkg/spec/test/folio-typed-amount.test.ts index d0a387a..2a75298 100644 --- a/pkg/spec/test/folio-typed-amount.test.ts +++ b/pkg/spec/test/folio-typed-amount.test.ts @@ -43,14 +43,39 @@ test("zero returns 0", () => { assert.equal(parseTypedAmount("0"), 0); }); -test("leading plus sign tolerated as positive", () => { - assert.equal(parseTypedAmount("+50"), 5000); +// The app's parseCents matches ^\d+(\.\d{1,2})?$ against the trimmed input, so +// a sign is rejected and no transaction is created. Reading "-50" as 5000 cents +// would make the balance property demand a move the app never made. +test("leading plus sign rejected, like the app", () => { + assert.equal(parseTypedAmount("+50"), 0); }); -test("leading minus sign tolerated as positive", () => { - assert.equal(parseTypedAmount("-50"), 5000); +test("leading minus sign rejected, like the app", () => { + assert.equal(parseTypedAmount("-50"), 0); }); test("comma-separated thousands accepted", () => { assert.equal(parseTypedAmount("1,234.56"), 123456); }); + +// The input corpus types this 21-digit run into every field. parseCents calls +// toLongOrNull on the whole part, which is null past Long.MAX, so the app +// refuses the submit; float64 would have read it as 1e23 and asked the property +// to find a balance move of 1e23 cents that never happened. +test("21-digit corpus amount returns 0: the app rejects it", () => { + assert.equal(parseTypedAmount("999999999999999999999"), 0); +}); + +test("amount too large for exact cents returns 0", () => { + assert.equal(parseTypedAmount("100000000000000"), 0); +}); + +// 9007199254740991 cents is Number.MAX_SAFE_INTEGER: the last amount whose +// cents survive the multiply intact. +test("largest exactly representable amount is kept", () => { + assert.equal(parseTypedAmount("90071992547409.91"), 9007199254740991); +}); + +test("one cent past the safe range returns 0", () => { + assert.equal(parseTypedAmount("90071992547409.92"), 0); +});