mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
homeTxnCountsOf keyed on the account name and let the last card win, so two accounts the fuzzer named the same collapsed into one entry. a reading that saw one Travel card and a later one that saw both then subtracted two different accounts' counts, and submitCommitsOneTransactionPerAction convicted a healthy app of double-submitting. it is a gated property in folio-run.sh, so that reads as "found the submit bug" over a card scrolling into view. same rule createdAccountHasNonZeroBalance already applies: a name nothing can attribute is no evidence. counted over every card, since an unreadable twin spoils the identity too.
453 lines
13 KiB
TypeScript
453 lines
13 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { test } from "node:test";
|
|
|
|
import {
|
|
cardTxnCount,
|
|
committedTransactionsExceedSubmits,
|
|
homeTxnCountsOf,
|
|
} from "../../../examples/folio/sanderling/predicates.ts";
|
|
|
|
// Android and iOS give the count its own node; web merges the card into one
|
|
// string, where the count sits between the name and the balance. The reading
|
|
// carries which of the two it came from: a number is a count nothing else could
|
|
// have leaked into, a string is a digit run that may have.
|
|
test("a dedicated count node reads as a number, not a digit run", () => {
|
|
assert.equal(
|
|
cardTxnCount({ childText: "12 transactions", cardText: "INInvestments12 transactions$2,589.00" }),
|
|
12,
|
|
);
|
|
});
|
|
|
|
test("merged card text: the count is taken from in front of the balance", () => {
|
|
assert.equal(
|
|
cardTxnCount({ childText: undefined, cardText: "INInvestments12 transactions$2,589.00" }),
|
|
"12",
|
|
);
|
|
});
|
|
|
|
test("merged card text: the singular label parses too", () => {
|
|
assert.equal(
|
|
cardTxnCount({ childText: undefined, cardText: "SASavings1 transaction$118.00" }),
|
|
"1",
|
|
);
|
|
});
|
|
|
|
// The balance has to come off first, or a name ending in digits would be read
|
|
// as the count.
|
|
test("a card with no readable count is unknown, not zero", () => {
|
|
assert.equal(cardTxnCount({ childText: undefined, cardText: undefined }), undefined);
|
|
assert.equal(cardTxnCount({ childText: undefined, cardText: "no digits here" }), undefined);
|
|
assert.equal(cardTxnCount({ childText: "", cardText: "AA" + "a".repeat(198) }), undefined);
|
|
});
|
|
|
|
// Measured on a real web run: the account named "-1" holding 2 transactions
|
|
// merges to "-1-12 transactions-$119.00", and the maximal digit run reads 12.
|
|
test("merged text runs a digit-ending name into the count", () => {
|
|
assert.equal(
|
|
cardTxnCount({ childText: undefined, cardText: "-1-12 transactions-$119.00" }),
|
|
"12",
|
|
);
|
|
});
|
|
|
|
const before = { Checking: "3", Savings: "1" };
|
|
|
|
test("healthy window: three submits, three transactions", () => {
|
|
assert.equal(
|
|
committedTransactionsExceedSubmits({
|
|
countsBefore: before,
|
|
countsAfter: { Checking: "5", Savings: "2" },
|
|
submitsInWindow: 3,
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("rejected submits commit nothing, which is under the bound", () => {
|
|
assert.equal(
|
|
committedTransactionsExceedSubmits({
|
|
countsBefore: before,
|
|
countsAfter: { Checking: "3", Savings: "1" },
|
|
submitsInWindow: 4,
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
// The bug, stated directly: one tap, two rows.
|
|
test("double submit: one action commits two transactions", () => {
|
|
assert.equal(
|
|
committedTransactionsExceedSubmits({
|
|
countsBefore: before,
|
|
countsAfter: { Checking: "5", Savings: "1" },
|
|
submitsInWindow: 1,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
// The point of counting actions against transactions rather than gating on a
|
|
// one-submit window: a wide window is still a sound comparison.
|
|
test("wide window: five submits committing six transactions still fires", () => {
|
|
assert.equal(
|
|
committedTransactionsExceedSubmits({
|
|
countsBefore: before,
|
|
countsAfter: { Checking: "8", Savings: "4" },
|
|
submitsInWindow: 5,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("boundary: committed equal to the submit count is not a violation", () => {
|
|
assert.equal(
|
|
committedTransactionsExceedSubmits({
|
|
countsBefore: before,
|
|
countsAfter: { Checking: "4", Savings: "1" },
|
|
submitsInWindow: 1,
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("boundary: one transaction past the submit count is", () => {
|
|
assert.equal(
|
|
committedTransactionsExceedSubmits({
|
|
countsBefore: before,
|
|
countsAfter: { Checking: "4", Savings: "2" },
|
|
submitsInWindow: 1,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
// Only accounts in both readings count. A card that scrolled out of the
|
|
// viewport, or one whose count was unreadable, drops out of the sum, so the
|
|
// result is a lower bound on what committed. Losing a card can only cost a
|
|
// detection; it must never manufacture one.
|
|
test("an account missing from the later reading is not counted", () => {
|
|
assert.equal(
|
|
committedTransactionsExceedSubmits({
|
|
countsBefore: { Checking: "3", Savings: "1" },
|
|
countsAfter: { Checking: "3" },
|
|
submitsInWindow: 0,
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("an account appearing only in the later reading is not counted", () => {
|
|
assert.equal(
|
|
committedTransactionsExceedSubmits({
|
|
countsBefore: { Checking: "3" },
|
|
countsAfter: { Checking: "3", Travel: "9" },
|
|
submitsInWindow: 0,
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("a card that scrolled away and back is not double counted", () => {
|
|
assert.equal(
|
|
committedTransactionsExceedSubmits({
|
|
countsBefore: { Checking: "3" },
|
|
countsAfter: { Checking: "4", Savings: "40" },
|
|
submitsInWindow: 1,
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("an unknown reading on either side is not evidence", () => {
|
|
assert.equal(
|
|
committedTransactionsExceedSubmits({
|
|
countsBefore: null,
|
|
countsAfter: { Checking: "99" },
|
|
submitsInWindow: 0,
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
committedTransactionsExceedSubmits({
|
|
countsBefore: { Checking: "0" },
|
|
countsAfter: null,
|
|
submitsInWindow: 0,
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
// The real trace this came from: at the violating step of seeds 3 and 5 the
|
|
// window held exactly one submit action and the account's count moved by two.
|
|
test("the measured web witness: submits 1, count delta 2", () => {
|
|
assert.equal(
|
|
committedTransactionsExceedSubmits({
|
|
countsBefore: { INInvestments: "12", Aa: "0" },
|
|
countsAfter: { INInvestments: "14", Aa: "0" },
|
|
submitsInWindow: 1,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
// The length rule, which is what keeps the merged-text prefix honest. The
|
|
// account named "-1" reads 19 at nine transactions and 110 at ten: same account,
|
|
// a delta of 91 out of a true delta of 1. Different run lengths are dropped.
|
|
test("a count crossing a digit boundary is dropped, not convicted on", () => {
|
|
assert.equal(
|
|
committedTransactionsExceedSubmits({
|
|
countsBefore: { "-1-": "19" },
|
|
countsAfter: { "-1-": "110" },
|
|
submitsInWindow: 1,
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("same run length keeps the prefixed delta exact", () => {
|
|
assert.equal(
|
|
committedTransactionsExceedSubmits({
|
|
countsBefore: { "-1-": "110" },
|
|
countsAfter: { "-1-": "112" },
|
|
submitsInWindow: 1,
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
committedTransactionsExceedSubmits({
|
|
countsBefore: { "-1-": "110" },
|
|
countsAfter: { "-1-": "111" },
|
|
submitsInWindow: 1,
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("an unreadably long run is not evidence", () => {
|
|
assert.equal(
|
|
committedTransactionsExceedSubmits({
|
|
countsBefore: { Checking: "1".repeat(21) },
|
|
countsAfter: { Checking: "9".repeat(21) },
|
|
submitsInWindow: 0,
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
// The other side of that rule, and the reason it is scoped to merged text: a
|
|
// count read off its own node has no account name in front of it, so its digits
|
|
// ARE the count and a decade crossing is just a number getting longer. Both
|
|
// windows below are real android seed-9 readings that the unscoped length rule
|
|
// threw away, in runs that then finished clean.
|
|
test("a dedicated node's count crossing a decade is usable evidence", () => {
|
|
assert.equal(
|
|
committedTransactionsExceedSubmits({
|
|
countsBefore: { Checking: 7 },
|
|
countsAfter: { Checking: 12 },
|
|
submitsInWindow: 1,
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
committedTransactionsExceedSubmits({
|
|
countsBefore: { Savings: 4 },
|
|
countsAfter: { Savings: 10 },
|
|
submitsInWindow: 1,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
// Recovering the window is only worth anything if it still acquits the healthy
|
|
// case, so the same crossing under a submit that earned it must not fire.
|
|
test("a dedicated node's healthy decade crossing does not convict", () => {
|
|
assert.equal(
|
|
committedTransactionsExceedSubmits({
|
|
countsBefore: { Checking: 9 },
|
|
countsAfter: { Checking: 10 },
|
|
submitsInWindow: 1,
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
committedTransactionsExceedSubmits({
|
|
countsBefore: { Checking: 9 },
|
|
countsAfter: { Checking: 11 },
|
|
submitsInWindow: 1,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
// The same numbers off merged text, where the digits may not be the count at
|
|
// all: still dropped.
|
|
test("the merged-text equivalent of that crossing is still dropped", () => {
|
|
assert.equal(
|
|
committedTransactionsExceedSubmits({
|
|
countsBefore: { Checking: "9" },
|
|
countsAfter: { Checking: "10" },
|
|
submitsInWindow: 0,
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
committedTransactionsExceedSubmits({
|
|
countsBefore: { Checking: "7" },
|
|
countsAfter: { Checking: "12" },
|
|
submitsInWindow: 1,
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
// The boundary itself. A pair whose two readings came from different sources is
|
|
// vouched for by neither rule: the string may carry a name prefix the number
|
|
// does not, so subtracting them is not a transaction count.
|
|
test("a pair straddling the two sources is not comparable", () => {
|
|
assert.equal(
|
|
committedTransactionsExceedSubmits({
|
|
countsBefore: { Checking: 7 },
|
|
countsAfter: { Checking: "12" },
|
|
submitsInWindow: 1,
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
committedTransactionsExceedSubmits({
|
|
countsBefore: { Checking: "7" },
|
|
countsAfter: { Checking: 12 },
|
|
submitsInWindow: 1,
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
// End to end from the two accessibility shapes, which is where the distinction
|
|
// is actually made: the same account, the same true counts, read once off a
|
|
// dedicated node and once off merged card text.
|
|
const dedicated = (name: string, count: number) => ({
|
|
name,
|
|
balance: 0,
|
|
count: cardTxnCount({ childText: `${count} transactions`, cardText: undefined }),
|
|
});
|
|
|
|
const merged = (initials: string, name: string, count: number) => ({
|
|
name,
|
|
balance: 0,
|
|
count: cardTxnCount({
|
|
childText: undefined,
|
|
cardText: `${initials}${name}${count} transactions$0.00`,
|
|
}),
|
|
});
|
|
|
|
test("a dedicated-node card list convicts across a decade", () => {
|
|
assert.equal(
|
|
committedTransactionsExceedSubmits({
|
|
countsBefore: homeTxnCountsOf([dedicated("Checking", 9)]),
|
|
countsAfter: homeTxnCountsOf([dedicated("Checking", 11)]),
|
|
submitsInWindow: 1,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("the merged-text card list drops the same pair", () => {
|
|
assert.equal(
|
|
committedTransactionsExceedSubmits({
|
|
countsBefore: homeTxnCountsOf([merged("CH", "Checking", 9)]),
|
|
countsAfter: homeTxnCountsOf([merged("CH", "Checking", 11)]),
|
|
submitsInWindow: 1,
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
// Home lists whatever fits the viewport, and Folio lets two accounts share a
|
|
// name, so one name can arrive on two cards. Keying counts by name collapsed
|
|
// them onto the last card, and the two readings a window compares then came off
|
|
// DIFFERENT cards: the probe below is a healthy app, one submit, and a scroll.
|
|
test("two cards sharing a name do not become one count", () => {
|
|
const before = homeTxnCountsOf([{ name: "Travel", balance: 0, count: 0 }]);
|
|
const after = homeTxnCountsOf([
|
|
{ name: "Travel", balance: 0, count: 0 },
|
|
{ name: "Travel", balance: 500, count: 8 },
|
|
]);
|
|
assert.equal(
|
|
committedTransactionsExceedSubmits({
|
|
countsBefore: before,
|
|
countsAfter: after,
|
|
submitsInWindow: 1,
|
|
}),
|
|
false,
|
|
);
|
|
assert.deepEqual(after, null);
|
|
});
|
|
|
|
test("a name on two cards is dropped from both readings", () => {
|
|
const before = homeTxnCountsOf([
|
|
{ name: "Travel", balance: 0, count: "3" },
|
|
{ name: "Travel", balance: 0, count: "9" },
|
|
{ name: "Checking", balance: 0, count: "2" },
|
|
]);
|
|
const after = homeTxnCountsOf([
|
|
{ name: "Travel", balance: 0, count: "3" },
|
|
{ name: "Travel", balance: 0, count: "11" },
|
|
{ name: "Checking", balance: 0, count: "2" },
|
|
]);
|
|
assert.deepEqual(before, { Checking: "2" });
|
|
assert.deepEqual(after, { Checking: "2" });
|
|
assert.equal(
|
|
committedTransactionsExceedSubmits({
|
|
countsBefore: before,
|
|
countsAfter: after,
|
|
submitsInWindow: 0,
|
|
}),
|
|
false,
|
|
);
|
|
});
|
|
|
|
// The other card need not be readable to spoil the identity: an unreadable
|
|
// count still means the name on the map may not be the card that was read.
|
|
test("a duplicate name is dropped even when the twin has no count", () => {
|
|
assert.deepEqual(
|
|
homeTxnCountsOf([
|
|
{ name: "Travel", balance: 0, count: 4 },
|
|
{ name: "Travel", balance: 0, count: undefined },
|
|
{ name: "Savings", balance: 0, count: 1 },
|
|
]),
|
|
{ Savings: 1 },
|
|
);
|
|
});
|
|
|
|
// Dropping the ambiguous name must not disable the property for the rest.
|
|
test("a unique name is still counted beside a dropped duplicate", () => {
|
|
const before = homeTxnCountsOf([
|
|
{ name: "Travel", balance: 0, count: 3 },
|
|
{ name: "Travel", balance: 0, count: 1 },
|
|
{ name: "Checking", balance: 0, count: 4 },
|
|
]);
|
|
const after = homeTxnCountsOf([
|
|
{ name: "Travel", balance: 0, count: 3 },
|
|
{ name: "Travel", balance: 0, count: 1 },
|
|
{ name: "Checking", balance: 0, count: 6 },
|
|
]);
|
|
assert.deepEqual(before, { Checking: 4 });
|
|
assert.equal(
|
|
committedTransactionsExceedSubmits({
|
|
countsBefore: before,
|
|
countsAfter: after,
|
|
submitsInWindow: 1,
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("distinct names are all counted", () => {
|
|
assert.deepEqual(
|
|
homeTxnCountsOf([
|
|
{ name: "Checking", balance: 0, count: "3" },
|
|
{ name: "Savings", balance: 0, count: "1" },
|
|
]),
|
|
{ Checking: "3", Savings: "1" },
|
|
);
|
|
});
|