social-kit / test / selfsync.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
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
/**
 * The `apps` self-sync section, against the committed dist.
 *
 * The golden-vector suite already deep-compares the whole section, but
 * `assert.deepEqual` is key-order-BLIND and cannot express a 16 KB state, a
 * clock, or a prototype — so three behaviours had no node coverage at all: a
 * mutant that deleted the `.sort()`, one that deleted the null-prototype
 * defense, and one that deleted the `ts` clamp all left the suite green. Every
 * test below is written to kill one of those, plus the unit of the size caps,
 * which is the one place this kit can silently disagree with id-kit and lose a
 * user's data between their own devices.
 *
 * The caps and their unit are id-kit's, not this kit's:
 *   per entry's `state`   16384 UTF-16 code units of JSON.stringify
 *   whole serialized map  98304 UTF-16 code units
 */

import test from "node:test";
import assert from "node:assert/strict";
import { sanitizeAppsSync, foldAppsSync } from "../dist/index.js";

const TS = 1735689600000;
const e = (state, ts = TS) => ({ state, ts });
const STATE_MAX = 16384;
const APPS_MAX = 98304;

// ---- size caps: the unit is code units, and it is id-kit's ------------------

test("the per-entry cap is 16384 UTF-16 code units of JSON.stringify(state)", () => {
  const at = "x".repeat(STATE_MAX - 2); // + 2 quotes = exactly the cap
  const over = "x".repeat(STATE_MAX - 1);
  assert.equal(JSON.stringify(at).length, STATE_MAX);
  assert.notEqual(sanitizeAppsSync({ a: e(at) }), null, "at the cap");
  assert.equal(sanitizeAppsSync({ a: e(over) }), null, "one unit over");
});

test("a two-byte-per-character state is measured in code units, not UTF-8 bytes", () => {
  // The regression this test exists for: the suite is en/ro/hu, and measuring
  // UTF-8 bytes rejected at half the cap what id-kit had already accepted and
  // persisted — the section saved locally and silently never crossed devices.
  const ro = "ș".repeat(STATE_MAX - 2);
  assert.equal(JSON.stringify(ro).length, STATE_MAX, "code units: exactly the cap");
  assert.equal(Buffer.byteLength(JSON.stringify(ro), "utf8"), 2 * STATE_MAX - 2, "UTF-8: twice that");
  assert.notEqual(sanitizeAppsSync({ a: e(ro) }), null, "id-kit stores it, so this kit must send it");
  assert.equal(sanitizeAppsSync({ a: e("ș".repeat(STATE_MAX - 1)) }), null, "one unit over is still over");
});

test("the whole map is capped at 98304 code units, skipping rather than stopping", () => {
  // one entry costs '"k":' + JSON.stringify(entry) + one separator
  //   = 3 + 1 + (31 + |S|) + 1 = 36 + |S| for a 1-char key and a 13-digit ts
  // five at the per-entry cap: 1 + 5 * (36 + 16382) = 82091, leaving 16213.
  const big = "x".repeat(STATE_MAX - 2);
  const five = { a: e(big), b: e(big), c: e(big), d: e(big), f: e(big) };

  const atCap = sanitizeAppsSync({ ...five, g: e("x".repeat(16177)) });
  assert.deepEqual(Object.keys(atCap), ["a", "b", "c", "d", "f", "g"]);
  assert.equal(JSON.stringify(atCap).length, APPS_MAX, "the arithmetic lands exactly on the cap");

  // `h` is one unit too big for what is left; `i` after it still fits
  const over = sanitizeAppsSync({ ...five, h: e("x".repeat(16178)), i: e("x".repeat(100)) });
  assert.deepEqual(Object.keys(over), ["a", "b", "c", "d", "f", "i"]);
  assert.ok(JSON.stringify(over).length <= APPS_MAX);
  assert.deepEqual(Object.keys(sanitizeAppsSync(over)), Object.keys(over), "a second pass changes nothing");
});

// ---- the ts clamp -----------------------------------------------------------

test("a future-dated ts is clamped to now, never to now + skew", () => {
  // Emitting now+skew froze the key: that device then rejected every honest
  // remote edit forever while broadcasting a perpetually-fresh timestamp that
  // beat every other device.
  const before = Date.now();
  const out = sanitizeAppsSync({ chat: e(1, before + 10 * 60 * 1000) });
  const after = Date.now();
  assert.ok(out.chat.ts <= after, `emitted ${out.chat.ts - after} ms ahead of now`);
  assert.ok(out.chat.ts >= before, "and not dragged into the past either");
});

test("the clamp leaves an honest past ts exactly alone", () => {
  assert.equal(sanitizeAppsSync({ chat: e(1, TS) }).chat.ts, TS);
});

test("a clamped entry stops moving, so the sync fingerprint settles", () => {
  // The second half of the freeze: because now+skew moved with the clock, the
  // emitted section differed every round and the hash gate never suppressed —
  // a mailbox deposit every cycle. Re-sanitizing a clamped entry must be a
  // fixed point.
  const once = sanitizeAppsSync({ chat: e(1, Date.now() + 60 * 60 * 1000) });
  const twice = sanitizeAppsSync(once);
  assert.equal(twice.chat.ts, once.chat.ts);
  assert.equal(JSON.stringify(twice), JSON.stringify(once));
});

// ---- key order is the wire --------------------------------------------------

test("sanitize emits keys in a deterministic order, from any input order", () => {
  const forward = sanitizeAppsSync({ board: e(1), chat: e(1), zed: e(1) });
  const backward = sanitizeAppsSync({ zed: e(1), chat: e(1), board: e(1) });
  // Object.keys, not the object: deepEqual on the objects is order-blind and
  // would pass with the sort deleted.
  assert.deepEqual(Object.keys(forward), ["board", "chat", "zed"]);
  assert.deepEqual(Object.keys(backward), ["board", "chat", "zed"]);
  assert.equal(JSON.stringify(forward), JSON.stringify(backward), "same bytes either way");
});

test("the fold emits keys in a deterministic order, from either role", () => {
  const l = { zed: e(1), board: e(1) };
  const r = { chat: e(1), apex: e(1) };
  assert.deepEqual(Object.keys(foldAppsSync(l, r)), ["apex", "board", "chat", "zed"]);
  assert.deepEqual(Object.keys(foldAppsSync(r, l)), ["apex", "board", "chat", "zed"]);
});

test("all-digit keys are legal and come out numerically, not lexicographically", () => {
  // The documented contract is DETERMINISM, not sorted order: the charset
  // permits "2" and "10", and JS canonical property order hoists array-index
  // -like keys numerically ahead of every string key.
  const out = sanitizeAppsSync({ board: e(1), 10: e(1), 2: e(1), a: e(1) });
  assert.deepEqual(Object.keys(out), ["2", "10", "a", "board"]);
  assert.deepEqual(Object.keys(foldAppsSync({ board: e(1), 10: e(1) }, { 2: e(1), a: e(1) })),
                   ["2", "10", "a", "board"]);
});

// ---- prototype keys ---------------------------------------------------------

test("a __proto__ own key never poisons the fold's output", () => {
  // Exactly what JSON.parse of wire text produces. Before the fix the key
  // vanished into Object.prototype's __proto__ SETTER and the entry became
  // the result's prototype — its state readable on the whole map.
  const wire = JSON.parse('{"__proto__":{"state":"pwned","ts":9}}');
  assert.ok(Object.hasOwn(wire, "__proto__"), "the input really carries it as an own key");
  const out = foldAppsSync(wire, {});
  assert.deepEqual(Object.keys(out), []);
  assert.equal(out.state, undefined, "no entry leaked onto the result");
  assert.equal(Object.getPrototypeOf(out), Object.prototype, "prototype untouched");
  assert.equal(foldAppsSync({}, wire).state, undefined, "and from the remote side too");
});

test("sanitize drops __proto__ as an ordinary charset failure", () => {
  const wire = JSON.parse('{"__proto__":{"state":"pwned","ts":9},"ok":{"state":1,"ts":1}}');
  const out = sanitizeAppsSync(wire);
  assert.deepEqual(Object.keys(out), ["ok"]);
  assert.equal(Object.getPrototypeOf(out), Object.prototype);
});

test('"constructor" is a legal app key and folds as ordinary data', () => {
  // It matches /^[a-z0-9-]{1,32}$/, so it is a key the suite could hand out —
  // and on an ordinary object it reads back as Object rather than undefined,
  // which is what the fold's null-prototype copies are for.
  const out = foldAppsSync({ constructor: e("mine", TS + 1) }, { constructor: e("theirs", TS) });
  assert.deepEqual(Object.keys(out), ["constructor"]);
  assert.deepEqual(out.constructor, { state: "mine", ts: TS + 1 });
  assert.deepEqual(foldAppsSync({}, { constructor: e("theirs") }).constructor, { state: "theirs", ts: TS });
});

test("a key only one side has is adopted, not folded against a prototype", () => {
  for (const k of ["constructor", "valueof", "hasownproperty"]) {
    const out = foldAppsSync({ [k]: e("kept") }, {});
    assert.deepEqual(out[k], { state: "kept", ts: TS }, k);
  }
});

// ---- totality ---------------------------------------------------------------

test("sanitize never throws, and a throwing entry costs only itself", () => {
  const bad = {};
  Object.defineProperty(bad, "ts", { get() { throw new Error("app code"); }, enumerable: true });
  const out = sanitizeAppsSync({ bad, good: e(1) });
  assert.deepEqual(Object.keys(out), ["good"]);
});

test("sanitize reads ts/state as OWN properties only", () => {
  const proto = { state: 1, ts: TS };
  assert.equal(sanitizeAppsSync({ a: Object.create(proto) }), null, "an inherited entry is not an entry");
});

test("the fold never throws on input the sanitizer never saw", () => {
  // canon() refuses a bigint; the hub calls foldAppsSync unwrapped.
  const out = foldAppsSync({ a: { state: 1n, ts: 1 } }, { a: { state: 2, ts: 1 } });
  assert.deepEqual(Object.keys(out), ["a"]);
  const thrower = {};
  Object.defineProperty(thrower, "chat", { get() { throw new Error("boom"); }, enumerable: true });
  assert.deepEqual(foldAppsSync(thrower, { chat: e(1) }), { chat: { state: 1, ts: TS } });
});

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/social-kit.git