mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
The runner now keeps lastAction and marks it relaunched: true where it used to report nothing at all, so the two properties that demand an effect judge a step whose process may have died before the write landed. submitChangesBalanceByTypedAmount and createdAccountHasNonZeroBalance both decline there. The counting bound does not: a relaunch cannot manufacture a transaction, and the submit is counted, so declining would throw away the detection the runner fix restored.
561 lines
16 KiB
TypeScript
561 lines
16 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { test } from "node:test";
|
|
|
|
import {
|
|
parseTypedAmount,
|
|
submitChangesBalanceByTypedAmount,
|
|
} from "../../../examples/folio/sanderling/predicates.ts";
|
|
|
|
const submitOn = "testTag:LedgerScreen > testTag:TxnSubmit";
|
|
|
|
test("single submit: delta matches typed amount", () => {
|
|
assert.equal(
|
|
submitChangesBalanceByTypedAmount({
|
|
route: "home",
|
|
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
|
submitsInWindow: 1,
|
|
typedAmount: 500,
|
|
prevTotalBalance: 1000,
|
|
currTotalBalance: 1500,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("double submit: delta is twice the typed amount, fires", () => {
|
|
assert.equal(
|
|
submitChangesBalanceByTypedAmount({
|
|
route: "home",
|
|
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
|
submitsInWindow: 1,
|
|
typedAmount: 500,
|
|
prevTotalBalance: 1000,
|
|
currTotalBalance: 2000,
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("DoubleTap kind also caught when delta exceeds typed amount", () => {
|
|
assert.equal(
|
|
submitChangesBalanceByTypedAmount({
|
|
route: "home",
|
|
lastAction: { kind: "DoubleTap", on: submitOn, applied: true },
|
|
submitsInWindow: 1,
|
|
typedAmount: 500,
|
|
prevTotalBalance: 0,
|
|
currTotalBalance: 1000,
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("wrong action kind: vacuous true even with mismatch", () => {
|
|
assert.equal(
|
|
submitChangesBalanceByTypedAmount({
|
|
route: "home",
|
|
lastAction: { kind: "InputText", on: submitOn, applied: true },
|
|
submitsInWindow: 1,
|
|
typedAmount: 500,
|
|
prevTotalBalance: 1000,
|
|
currTotalBalance: 1000,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("wrong target: vacuous true even with mismatch", () => {
|
|
assert.equal(
|
|
submitChangesBalanceByTypedAmount({
|
|
route: "home",
|
|
lastAction: { kind: "Tap", on: "testTag:LoginScreen > testTag:LoginSubmit", applied: true },
|
|
submitsInWindow: 1,
|
|
typedAmount: 500,
|
|
prevTotalBalance: 1000,
|
|
currTotalBalance: 1000,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("null lastAction: vacuous true", () => {
|
|
assert.equal(
|
|
submitChangesBalanceByTypedAmount({
|
|
route: "home",
|
|
lastAction: null,
|
|
submitsInWindow: 1,
|
|
typedAmount: 500,
|
|
prevTotalBalance: 1000,
|
|
currTotalBalance: 2000,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("zero typedAmount: vacuous true", () => {
|
|
assert.equal(
|
|
submitChangesBalanceByTypedAmount({
|
|
route: "home",
|
|
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
|
submitsInWindow: 1,
|
|
typedAmount: 0,
|
|
prevTotalBalance: 1000,
|
|
currTotalBalance: 1500,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("selector as object: coerced safely and TxnSubmit detected", () => {
|
|
assert.equal(
|
|
submitChangesBalanceByTypedAmount({
|
|
route: "home",
|
|
lastAction: { kind: "Tap", on: { testTag: "TxnSubmit" }, applied: true },
|
|
submitsInWindow: 1,
|
|
typedAmount: 500,
|
|
prevTotalBalance: 0,
|
|
currTotalBalance: 1000,
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("selector as object without TxnSubmit: vacuous true", () => {
|
|
assert.equal(
|
|
submitChangesBalanceByTypedAmount({
|
|
route: "home",
|
|
lastAction: { kind: "Tap", on: { testTag: "LoginSubmit" }, applied: true },
|
|
submitsInWindow: 1,
|
|
typedAmount: 500,
|
|
prevTotalBalance: 0,
|
|
currTotalBalance: 1000,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("raw whole-dollar input: single submit clears", () => {
|
|
assert.equal(
|
|
submitChangesBalanceByTypedAmount({
|
|
route: "home",
|
|
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
|
submitsInWindow: 1,
|
|
typedAmount: parseTypedAmount("50"),
|
|
prevTotalBalance: 5000,
|
|
currTotalBalance: 10000,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("raw whole-dollar input: double submit fires", () => {
|
|
assert.equal(
|
|
submitChangesBalanceByTypedAmount({
|
|
route: "home",
|
|
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
|
submitsInWindow: 1,
|
|
typedAmount: parseTypedAmount("50"),
|
|
prevTotalBalance: 5000,
|
|
currTotalBalance: 15000,
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("decimal input from empty prior balance clears", () => {
|
|
assert.equal(
|
|
submitChangesBalanceByTypedAmount({
|
|
route: "home",
|
|
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
|
submitsInWindow: 1,
|
|
typedAmount: parseTypedAmount("5.50"),
|
|
prevTotalBalance: 0,
|
|
currTotalBalance: 550,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("DoubleTap kind with raw whole-dollar input fires", () => {
|
|
assert.equal(
|
|
submitChangesBalanceByTypedAmount({
|
|
route: "home",
|
|
lastAction: { kind: "DoubleTap", on: submitOn, applied: true },
|
|
submitsInWindow: 1,
|
|
typedAmount: parseTypedAmount("100"),
|
|
prevTotalBalance: 0,
|
|
currTotalBalance: 20000,
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("route gate: ledger landing with stale carrier is skipped", () => {
|
|
assert.equal(
|
|
submitChangesBalanceByTypedAmount({
|
|
route: "ledger",
|
|
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
|
submitsInWindow: 1,
|
|
typedAmount: 5000,
|
|
prevTotalBalance: 0,
|
|
currTotalBalance: 0,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("route gate: add-transaction landing with double-submit delta is skipped", () => {
|
|
assert.equal(
|
|
submitChangesBalanceByTypedAmount({
|
|
route: "add-transaction",
|
|
lastAction: { kind: "DoubleTap", on: submitOn, applied: true },
|
|
submitsInWindow: 1,
|
|
typedAmount: 5000,
|
|
prevTotalBalance: 0,
|
|
currTotalBalance: 10000,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("route gate: null route is skipped", () => {
|
|
assert.equal(
|
|
submitChangesBalanceByTypedAmount({
|
|
route: null,
|
|
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
|
submitsInWindow: 1,
|
|
typedAmount: 5000,
|
|
prevTotalBalance: 0,
|
|
currTotalBalance: 0,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("route gate: home landing with matching delta passes", () => {
|
|
assert.equal(
|
|
submitChangesBalanceByTypedAmount({
|
|
route: "home",
|
|
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
|
submitsInWindow: 1,
|
|
typedAmount: 5000,
|
|
prevTotalBalance: 0,
|
|
currTotalBalance: 5000,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("route gate: home landing with double-insert delta fires", () => {
|
|
assert.equal(
|
|
submitChangesBalanceByTypedAmount({
|
|
route: "home",
|
|
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
|
submitsInWindow: 1,
|
|
typedAmount: 5000,
|
|
prevTotalBalance: 0,
|
|
currTotalBalance: 10000,
|
|
}),
|
|
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, applied: true },
|
|
submitsInWindow: 1,
|
|
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, applied: true },
|
|
submitsInWindow: 1,
|
|
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, applied: true },
|
|
submitsInWindow: 1,
|
|
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, applied: true },
|
|
submitsInWindow: 1,
|
|
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, applied: true },
|
|
submitsInWindow: 1,
|
|
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, applied: true },
|
|
submitsInWindow: 1,
|
|
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, applied: true },
|
|
submitsInWindow: 1,
|
|
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, applied: true },
|
|
submitsInWindow: 1,
|
|
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, applied: true },
|
|
submitsInWindow: 1,
|
|
typedAmount: parseTypedAmount("999999999999999999999"),
|
|
prevTotalBalance: 220900,
|
|
currTotalBalance: 220900,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
// Freshness. prevTotalBalance is the last total we READ, so the window between
|
|
// it and now can hold more than one submit's transactions. A delta measured
|
|
// over such a window is not evidence about the amount typed into any one of
|
|
// them, and the android run that produced a 13000 delta against a typed 19600
|
|
// is what that looks like: the window held a double-submit's two 19600 debits
|
|
// and an unrelated 26200 credit.
|
|
test("freshness: two submits in the window is vacuous, not a conviction", () => {
|
|
assert.equal(
|
|
submitChangesBalanceByTypedAmount({
|
|
route: "home",
|
|
lastAction: { kind: "DoubleTap", on: submitOn, applied: true },
|
|
submitsInWindow: 2,
|
|
typedAmount: 19600,
|
|
prevTotalBalance: 0,
|
|
currTotalBalance: -13000,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("freshness: two submits cannot convict even on a clean 2x delta", () => {
|
|
assert.equal(
|
|
submitChangesBalanceByTypedAmount({
|
|
route: "home",
|
|
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
|
submitsInWindow: 2,
|
|
typedAmount: 500,
|
|
prevTotalBalance: 1000,
|
|
currTotalBalance: 2000,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
// The boundary of the rule, from both sides. One submit is the only window the
|
|
// property judges: zero means the total moved without a submit landing in it
|
|
// (nothing to attribute the move to), and two or more means the move is shared.
|
|
test("freshness boundary: exactly one submit is the window that convicts", () => {
|
|
assert.equal(
|
|
submitChangesBalanceByTypedAmount({
|
|
route: "home",
|
|
lastAction: { kind: "DoubleTap", on: submitOn, applied: true },
|
|
submitsInWindow: 1,
|
|
typedAmount: 19600,
|
|
prevTotalBalance: 0,
|
|
currTotalBalance: -39200,
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("freshness boundary: one submit with a healthy 1x delta still passes", () => {
|
|
assert.equal(
|
|
submitChangesBalanceByTypedAmount({
|
|
route: "home",
|
|
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
|
submitsInWindow: 1,
|
|
typedAmount: 19600,
|
|
prevTotalBalance: 0,
|
|
currTotalBalance: -19600,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("freshness boundary: three submits is vacuous", () => {
|
|
assert.equal(
|
|
submitChangesBalanceByTypedAmount({
|
|
route: "home",
|
|
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
|
submitsInWindow: 3,
|
|
typedAmount: 500,
|
|
prevTotalBalance: 0,
|
|
currTotalBalance: 2500,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
// A zero count would mean the step's own action was not counted as a submit,
|
|
// which contradicts the action gate above it. Guard it anyway: a window with no
|
|
// submit in it explains no balance move.
|
|
test("freshness boundary: a window with no submit in it is vacuous", () => {
|
|
assert.equal(
|
|
submitChangesBalanceByTypedAmount({
|
|
route: "home",
|
|
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
|
submitsInWindow: 0,
|
|
typedAmount: 500,
|
|
prevTotalBalance: 1000,
|
|
currTotalBalance: 2000,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
// applied: null is the runner saying it dispatched the tap and never learned
|
|
// whether it landed. A submit that committed nothing leaves the balance where
|
|
// it was, so demanding the typed amount of movement for it convicts an app that
|
|
// did exactly what it should have.
|
|
test("a submit the runner could not confirm demands no balance move", () => {
|
|
assert.equal(
|
|
submitChangesBalanceByTypedAmount({
|
|
route: "home",
|
|
lastAction: { kind: "Tap", on: submitOn, applied: null },
|
|
submitsInWindow: 1,
|
|
typedAmount: 500,
|
|
prevTotalBalance: 1000,
|
|
currTotalBalance: 1000,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
// relaunched: true is the runner saying its foreground guard restarted the app
|
|
// after this action. The tap landed, so the window still counts it, but nobody
|
|
// can promise the process lived long enough for the write to reach sqlite. A
|
|
// balance still sitting where it was is exactly what a healthy app looks like
|
|
// across a relaunch, and demanding the typed amount of movement convicts it for
|
|
// the runner's own restart.
|
|
test("a submit the runner relaunched across demands no balance move", () => {
|
|
assert.equal(
|
|
submitChangesBalanceByTypedAmount({
|
|
route: "home",
|
|
lastAction: { kind: "Tap", on: submitOn, applied: true, relaunched: true },
|
|
submitsInWindow: 1,
|
|
typedAmount: 500,
|
|
prevTotalBalance: 1000,
|
|
currTotalBalance: 1000,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
// The guard must not become a way of switching the property off. No relaunch
|
|
// reported is the ordinary case, and web and iOS cannot report one at all.
|
|
test("no relaunch reported still convicts a double submit", () => {
|
|
for (const relaunched of [null, undefined]) {
|
|
assert.equal(
|
|
submitChangesBalanceByTypedAmount({
|
|
route: "home",
|
|
lastAction: { kind: "DoubleTap", on: submitOn, applied: true, relaunched },
|
|
submitsInWindow: 1,
|
|
typedAmount: 500,
|
|
prevTotalBalance: 1000,
|
|
currTotalBalance: 2000,
|
|
}),
|
|
false,
|
|
);
|
|
}
|
|
});
|