banca / client / test / link.test.mjs
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
// The bank link — `#<secret>.<bankerPub>` (BANK/1) or
// `#<secret>.<bankerPub>.<v>` (BANK/2 and later).
//
// docs/PROTOCOL.md §1: authority is a pure function of the link, so the banker
// is known before a single op is read. This file pins the SPLIT, and pins the
// two things it must refuse: a token whose halves are not both link-shaped, and
// a bare secret being mistaken for a bank.
//
// Runs against test-dist/testlib.js — the built output — never src/.

import test from "node:test";
import assert from "node:assert/strict";
import * as L from "../test-dist/testlib.js";

const S = "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg"; // 43 base64url
const B = "0EqyMnQrtKs6E2i9RhXk5tAiSrcaAWuvhSCjMsl3hzc";

test("a bank link splits into a secret and a banker, and rebuilds", () => {
  const p = L.parseLink(`${S}.${B}`);
  assert.deepEqual(Object.keys(p), ["secret", "banker", "v"]);
  assert.equal(p.v, 1, "an absent suffix IS version 1");
  assert.equal(p.secret, S);
  assert.equal(p.banker, B);
  assert.equal(L.fmtLink(p.secret, p.banker), `${S}.${B}`);
  assert.equal(L.linkSecret(`${S}.${B}`), S);
  assert.equal(L.linkBanker(`${S}.${B}`), B);
  assert.equal(L.isBankLink(`${S}.${B}`), true);
});

test("a bare secret parses, and is NOT a bank", () => {
  // §1: "a link without the `.<bankerPub>` suffix is not a bank". The room still
  // opens and replicates — that is what a reader holding half a link should see
  // — but nothing folds, so the interface has to be able to tell them apart.
  const p = L.parseLink(S);
  assert.equal(p.secret, S);
  assert.equal(p.banker, null);
  assert.equal(L.isBankLink(S), false);
  assert.equal(L.linkBanker(S), null);
  assert.equal(L.fmtLink(S, null), S, "a round trip through fmt is lossless both ways");
  assert.equal(L.fmtLink(S, undefined), S);
});

test("a malformed token is nil, never a repaired one", () => {
  const bad = [
    "", ".", `.${B}`, `${S}.`, `${S}.${B}.${B}`,
    S.slice(0, 42), S + "A", `${S}.${B.slice(0, 42)}`,
    `${S.slice(0, 42)}+.${B}`,            // '+' is base64, not base64url
    `${S}/${B}`, `${S}#${B}`, `${S} ${B}`,
    `${S}.${B} `, ` ${S}.${B}`,
    null, undefined, 7, {}, [], true,
  ];
  for (const x of bad) assert.equal(L.parseLink(x), null, JSON.stringify(x));
  // …and every one of them is also "not a bank", which is the question the UI
  // actually asks
  for (const x of bad) assert.equal(L.isBankLink(x), false, JSON.stringify(x));
});

test("the split is not vacuous — it really is the two halves, in order", () => {
  // A parser that returned {secret: whole, banker: whole} would pass a
  // "parses/doesn't parse" test and hand BankFold the wrong key, which folds to
  // an empty bank with no error anywhere.
  const p = L.parseLink(`${S}.${B}`);
  assert.notEqual(p.secret, p.banker);
  assert.equal(p.secret.length, 43);
  assert.equal(p.banker.length, 43);
  assert.equal(L.parseLink(`${B}.${S}`).secret, B, "the halves are positional, not sorted");
});

test("link shape is checked here; KEY validity is the fold's, deliberately", () => {
  // The banker half of this link is 43 base64url characters and is NOT a
  // canonical Ed25519 key (wallet-kit's PUB_RE pins the last character's bits).
  // link/parse accepts it on shape, and BankFold refuses it on substance — one
  // authority, not two opinions that can disagree.
  // 43 base64url characters whose LAST one carries bits a 32-byte key cannot
  // have — wallet-kit's PUB_RE ends `[AEIMQUYcgkosw048]`, and "B" is not in it.
  const nonCanonical = "A".repeat(42) + "B";
  assert.ok(L.parseLink(`${S}.${nonCanonical}`), "shape passes");
  assert.equal(new L.BankFold(nonCanonical, null).bankerPub, null, "the fold refuses it");
  assert.equal(new L.BankFold(B, null).bankerPub, B, "…and accepts a real one");
});

// ---- the version suffix -----------------------------------------------------

test("a version suffix parses, and an absent one reads as 1", () => {
  const p = L.parseLink(`${S}.${B}.2`);
  assert.deepEqual(Object.keys(p), ["secret", "banker", "v"]);
  assert.equal(p.secret, S);
  assert.equal(p.banker, B);
  assert.equal(p.v, 2);
  assert.equal(L.linkVersion(`${S}.${B}.2`), 2);
  assert.equal(L.linkVersion(`${S}.${B}`), 1);
  assert.equal(L.linkVersion(S), 1, "a bare secret is still a v1 room");
  assert.equal(L.linkVersion("nonsense"), null);
  assert.equal(L.isBankLink(`${S}.${B}.2`), true);
  assert.equal(L.linkSecret(`${S}.${B}.2`), S);
  assert.equal(L.linkBanker(`${S}.${B}.2`), B);
});

test("`.1` is refused — one bank, one spelling", () => {
  // The absent suffix IS version 1. Accepting `.1` as a synonym would give one
  // bank two link spellings, which every string-keyed thing in this app (the
  // saved-bank list, the public-bank dedup) would then see as two banks.
  assert.equal(L.parseLink(`${S}.${B}.1`), null);
  assert.equal(L.isBankLink(`${S}.${B}.1`), false);
});

test("fmt round-trips a version, and never writes `.1`", () => {
  assert.equal(L.fmtLink(S, B, 2), `${S}.${B}.2`);
  assert.equal(L.fmtLink(S, B, 1), `${S}.${B}`);
  assert.equal(L.fmtLink(S, B), `${S}.${B}`, "the default arity is v1");
  assert.equal(L.fmtLink(S, null, 2), S, "no banker, no version — a bare secret");
  const p = L.parseLink(`${S}.${B}.2`);
  assert.equal(L.fmtLink(p.secret, p.banker, p.v), `${S}.${B}.2`, "lossless");
});

test("a malformed version is not a link at all", () => {
  const bad = ["0", "1", "01", "02", "2.", "a", "-1", "1000", "2e0", "٢", "", " 2", "2 ", "+2", "2.5"];
  for (const v of bad) {
    assert.equal(L.parseLink(`${S}.${B}.${v}`), null, JSON.stringify(v));
    assert.equal(L.isBankLink(`${S}.${B}.${v}`), false, JSON.stringify(v));
  }
});

test("a version this build cannot fold parses but is NOT supported", () => {
  // The distinction is the whole point: "update" is the honest message, and
  // "broken link" is a lie that sends someone looking for a typo. Folding a
  // bank whose op union you do not know is how a replica ends up quietly
  // disagreeing about who owns what.
  const future = L.LINK_MAX_VERSION + 1;
  const tok = `${S}.${B}.${future}`;
  assert.ok(L.parseLink(tok), "it is a well-formed link");
  assert.equal(L.linkVersion(tok), future);
  assert.equal(L.linkSupported(tok), false, "and we refuse to fold it");
  assert.equal(L.linkSupported(`${S}.${B}`), true);
  assert.equal(L.linkSupported(`${S}.${B}.2`), true);
  assert.equal(L.linkSupported("nonsense"), false, "not a link is not supported either");
});

test("the salt follows the bank's version, and v1 is byte-for-byte unchanged", () => {
  // If both versions shared a salt, one secret would derive ONE room, and a
  // banker who appended `.2` to their own link would open their EXISTING bank
  // under v2 rules — writing escrow ops into a log v1 clients are still reading
  // and dropping silently. Self-inflicted, one keystroke away, and invisible.
  assert.equal(L.saltFor(1), "banca.ardegazu.ro/v1");
  assert.equal(L.saltFor(null), "banca.ardegazu.ro/v1", "an absent version is v1");
  assert.equal(L.saltFor(2), "banca.ardegazu.ro/v2");
  assert.notEqual(L.saltFor(1), L.saltFor(2));
});

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/banca.git