mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
the corpus reaches TxnSubmit with 999999999999999999999, AMOUNT_REGEX takes it and AddTransactionViewModel refuses it against MAX_TRANSACTION_AMOUNT_CENTS, so counting it was budget a double submit could hide behind.
286 lines
11 KiB
TypeScript
286 lines
11 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { test } from "node:test";
|
|
|
|
import {
|
|
committedTransactionsExceedSubmits,
|
|
countSubmitsInWindow,
|
|
isTxnSubmitTap,
|
|
readHomeTotalBalance,
|
|
} from "../../../examples/folio/sanderling/predicates.ts";
|
|
|
|
const submitOn = "testTag:AddTransactionScreen > testTag:TxnSubmit";
|
|
|
|
test("a tap on TxnSubmit is a commit", () => {
|
|
assert.equal(isTxnSubmitTap({ kind: "Tap", on: submitOn }), true);
|
|
});
|
|
|
|
test("a double-tap on TxnSubmit is ONE commit action, not two", () => {
|
|
const window = countSubmitsInWindow({
|
|
previousCount: 0,
|
|
lastAction: { kind: "DoubleTap", on: submitOn },
|
|
fresh: true,
|
|
});
|
|
assert.equal(window.reported, 1);
|
|
});
|
|
|
|
test("a selector object naming TxnSubmit is a commit", () => {
|
|
assert.equal(isTxnSubmitTap({ kind: "Tap", on: { testTag: "TxnSubmit" } }), true);
|
|
});
|
|
|
|
test("typing into the amount field is not a commit", () => {
|
|
assert.equal(isTxnSubmitTap({ kind: "InputText", on: submitOn }), false);
|
|
});
|
|
|
|
test("tapping some other button is not a commit", () => {
|
|
assert.equal(isTxnSubmitTap({ kind: "Tap", on: "testTag:AddAccountSubmit" }), false);
|
|
});
|
|
|
|
test("no action at all is not a commit", () => {
|
|
assert.equal(isTxnSubmitTap(null), false);
|
|
});
|
|
|
|
test("a fresh Home reading closes the window and the next one starts empty", () => {
|
|
assert.deepEqual(
|
|
countSubmitsInWindow({ previousCount: 0, lastAction: { kind: "Tap", on: submitOn }, fresh: true }),
|
|
{ reported: 1, next: 0 },
|
|
);
|
|
});
|
|
|
|
test("landing off Home keeps the submit in the window for the next step", () => {
|
|
assert.deepEqual(
|
|
countSubmitsInWindow({ previousCount: 0, lastAction: { kind: "Tap", on: submitOn }, fresh: false }),
|
|
{ reported: 1, next: 1 },
|
|
);
|
|
});
|
|
|
|
test("a non-submit step neither adds to nor forgets the window", () => {
|
|
assert.deepEqual(
|
|
countSubmitsInWindow({ previousCount: 1, lastAction: { kind: "Tap", on: "testTag:AccountCard" }, fresh: false }),
|
|
{ reported: 1, next: 1 },
|
|
);
|
|
});
|
|
|
|
test("a second submit with no Home reading between them counts two", () => {
|
|
assert.deepEqual(
|
|
countSubmitsInWindow({ previousCount: 1, lastAction: { kind: "DoubleTap", on: submitOn }, fresh: true }),
|
|
{ reported: 2, next: 0 },
|
|
);
|
|
});
|
|
|
|
// The window is a budget: an upper bound on the transactions the interval could
|
|
// hold. A tap the app's own parser must have refused spends none of it, and on
|
|
// android it does not even reach the parser, because TxnSubmit is
|
|
// clickable(enabled = amount.isNotBlank()). Measured over four recorded android
|
|
// runs, 19, 11, 25 and 25 of 35, 26, 42 and 42 submit taps landed on the
|
|
// transaction screen with the amount field empty, so more than half the budget
|
|
// was being spent on taps that cannot commit anything.
|
|
test("a submit the app must have refused does not spend the window's budget", () => {
|
|
for (const amountText of ["", " ", "0", "0.00", "00", "5.", "abc"]) {
|
|
assert.deepEqual(
|
|
countSubmitsInWindow({
|
|
previousCount: 0,
|
|
lastAction: { kind: "Tap", on: submitOn },
|
|
amountText,
|
|
fresh: false,
|
|
}),
|
|
{ reported: 0, next: 0 },
|
|
`amount ${JSON.stringify(amountText)} was counted as a possible commit`,
|
|
);
|
|
}
|
|
});
|
|
|
|
// Folio caps a transaction at $1,000,000.00 (MAX_TRANSACTION_AMOUNT_CENTS, in
|
|
// core/data/Repository.kt), and AddTransactionViewModel.submit refuses anything
|
|
// over it before a coroutine starts. The fuzzer's corpus carries
|
|
// "999999999999999999999", AMOUNT_REGEX lets it into the field and it reaches
|
|
// the button, so this is a refusal the window used to pay for.
|
|
test("an amount over the app's cap cannot commit", () => {
|
|
for (const amountText of ["1000000.01", "1,000,001", "999999999999999999999"]) {
|
|
assert.deepEqual(
|
|
countSubmitsInWindow({
|
|
previousCount: 0,
|
|
lastAction: { kind: "Tap", on: submitOn },
|
|
amountText,
|
|
fresh: false,
|
|
}),
|
|
{ reported: 0, next: 0 },
|
|
`amount ${JSON.stringify(amountText)} was counted as a possible commit`,
|
|
);
|
|
}
|
|
});
|
|
|
|
// The field as the landing frame shows it, which is the form state the tap read:
|
|
// nothing between the two changes it. Anywhere but the transaction screen there
|
|
// is no field to read, and unknown has to count. The cap itself is an amount the
|
|
// app takes, so it counts too.
|
|
test("an amount that could commit, or that nobody could read, spends the budget", () => {
|
|
for (const amountText of ["5", "0.01", "1,000", "1000000.00", "999999.99", undefined]) {
|
|
assert.deepEqual(
|
|
countSubmitsInWindow({
|
|
previousCount: 0,
|
|
lastAction: { kind: "Tap", on: submitOn },
|
|
amountText,
|
|
fresh: false,
|
|
}),
|
|
{ reported: 1, next: 1 },
|
|
`amount ${JSON.stringify(amountText)} was dropped from the budget`,
|
|
);
|
|
}
|
|
});
|
|
|
|
// The one thing that can put a different form state on screen than the one the
|
|
// tap read: the runner restarting the app, which the tap survives and the typed
|
|
// amount does not. The field a fresh process draws is empty whatever was
|
|
// submitted, so it proves nothing and the submit keeps its place in the budget.
|
|
test("a submit across a relaunch spends the budget whatever the field shows", () => {
|
|
assert.deepEqual(
|
|
countSubmitsInWindow({
|
|
previousCount: 0,
|
|
lastAction: { kind: "Tap", on: submitOn, applied: true, relaunched: true },
|
|
amountText: "",
|
|
fresh: false,
|
|
}),
|
|
{ reported: 1, next: 1 },
|
|
);
|
|
});
|
|
|
|
// What the budget costs the counting invariant, in the shape of the iOS run in
|
|
// #78: a stretch of the walk that never went Home, most of it taps on a submit
|
|
// button with nothing typed into the form, and one double tap that committed
|
|
// twice. Counting the refused taps hands the app five transactions of slack it
|
|
// never used, and two rows against six actions is no violation.
|
|
test("refused submits used to hide a double submit behind their own budget", () => {
|
|
const frames = [
|
|
{ amountText: "", lastAction: { kind: "Tap", on: submitOn } },
|
|
{ amountText: "", lastAction: { kind: "Tap", on: submitOn } },
|
|
{ amountText: "", lastAction: { kind: "Tap", on: submitOn } },
|
|
{ amountText: "", lastAction: { kind: "Tap", on: submitOn } },
|
|
{ amountText: "", lastAction: { kind: "Tap", on: submitOn } },
|
|
{ amountText: undefined, lastAction: { kind: "DoubleTap", on: submitOn } },
|
|
];
|
|
let budget = 0;
|
|
for (const frame of frames) {
|
|
budget = countSubmitsInWindow({
|
|
previousCount: budget,
|
|
lastAction: frame.lastAction,
|
|
amountText: frame.amountText,
|
|
fresh: false,
|
|
}).next;
|
|
}
|
|
assert.equal(budget, 1);
|
|
assert.equal(
|
|
committedTransactionsExceedSubmits({
|
|
countsBefore: { Checking: 3 },
|
|
countsAfter: { Checking: 5 },
|
|
submitsInWindow: budget,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
// The two traces the freshness rule exists to tell apart, driven step by step
|
|
// through the same pair of carriers the spec holds.
|
|
function run(steps: { route: string | null; totalText?: string; lastAction: unknown }[]) {
|
|
let carrier: number | null = null;
|
|
let submits = 0;
|
|
const out: { total: number | null; submits: number }[] = [];
|
|
for (const step of steps) {
|
|
const reading = readHomeTotalBalance({
|
|
route: step.route,
|
|
totalText: step.totalText,
|
|
previousCarrier: carrier,
|
|
});
|
|
carrier = reading.carrier;
|
|
const window = countSubmitsInWindow({
|
|
previousCount: submits,
|
|
lastAction: step.lastAction as { kind?: string; on?: string } | null,
|
|
fresh: reading.fresh,
|
|
});
|
|
submits = window.next;
|
|
out.push({ total: reading.value, submits: window.reported });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const idle = { kind: "Tap", on: "testTag:AccountCard" };
|
|
const submit = { kind: "Tap", on: submitOn };
|
|
const doubleSubmit = { kind: "DoubleTap", on: submitOn };
|
|
|
|
// A double-submit pops the back stack twice (each Submit calls
|
|
// navigator.back), so unlike a healthy single submit it lands back on Home,
|
|
// which is why the property can see it at all.
|
|
test("clean double submit: one action in the window, delta is 2x", () => {
|
|
const trace = run([
|
|
{ route: "home", totalText: "$0.00", lastAction: null },
|
|
{ route: "ledger", lastAction: idle },
|
|
{ route: "ledger", lastAction: idle },
|
|
{ route: "home", totalText: "$100.00", lastAction: doubleSubmit },
|
|
]);
|
|
assert.equal(trace[3]?.submits, 1);
|
|
assert.equal(trace[0]?.total, 0);
|
|
assert.equal(trace[3]?.total, 10000);
|
|
});
|
|
|
|
// The contaminated window from the android run: an unrelated submit committed
|
|
// while we were off Home, then the double-submit landed. The delta spans three
|
|
// transactions, so it is not evidence about either typed amount.
|
|
test("two submits between Home visits: the window is not evidence", () => {
|
|
const trace = run([
|
|
{ route: "home", totalText: "$0.00", lastAction: null },
|
|
{ route: "ledger", lastAction: idle },
|
|
{ route: "ledger", lastAction: submit },
|
|
{ route: "ledger", lastAction: idle },
|
|
{ route: "home", totalText: "-$130.00", lastAction: doubleSubmit },
|
|
]);
|
|
assert.equal(trace[4]?.submits, 2);
|
|
});
|
|
|
|
// Freshness is restored by seeing Home, not by time passing.
|
|
test("a Home visit between two submits restores a one-action window", () => {
|
|
const trace = run([
|
|
{ route: "home", totalText: "$0.00", lastAction: null },
|
|
{ route: "ledger", lastAction: submit },
|
|
{ route: "home", totalText: "$262.00", lastAction: idle },
|
|
{ route: "ledger", lastAction: idle },
|
|
{ route: "home", totalText: "$66.00", lastAction: doubleSubmit },
|
|
]);
|
|
assert.equal(trace[1]?.submits, 1);
|
|
assert.equal(trace[2]?.submits, 1);
|
|
assert.equal(trace[4]?.submits, 1);
|
|
});
|
|
|
|
// An unreadable Home is not a Home reading: it must not close the window, or
|
|
// the count would go back to zero against a total nobody read.
|
|
test("an unreadable Home does not close the window", () => {
|
|
const trace = run([
|
|
{ route: "home", totalText: "$0.00", lastAction: null },
|
|
{ route: "ledger", lastAction: submit },
|
|
{ route: "home", totalText: undefined, lastAction: idle },
|
|
{ route: "home", totalText: "$66.00", lastAction: doubleSubmit },
|
|
]);
|
|
assert.equal(trace[2]?.total, null);
|
|
assert.equal(trace[3]?.submits, 2);
|
|
});
|
|
|
|
// The window is an upper bound on the submits it holds, so a submit whose
|
|
// dispatch the runner could not confirm belongs in it: the tap may well have
|
|
// landed, and a bound that leaves it out is one the transaction it committed
|
|
// exceeds. That is the false conviction, a rise of one against a window of
|
|
// zero, on the property carrying most of the detection on android.
|
|
test("a submit the runner could not confirm still counts toward the window", () => {
|
|
const window = countSubmitsInWindow({
|
|
previousCount: 0,
|
|
lastAction: { kind: "Tap", on: submitOn, applied: null },
|
|
fresh: true,
|
|
});
|
|
assert.equal(window.reported, 1);
|
|
assert.equal(
|
|
committedTransactionsExceedSubmits({
|
|
countsBefore: { Travel: 3 },
|
|
countsAfter: { Travel: 4 },
|
|
submitsInWindow: window.reported,
|
|
}),
|
|
false,
|
|
);
|
|
});
|