| … | … | @@ -0,0 +1,174 @@ |
| 1 | // Engine parity — the SAME contract, run against autobase and against autobee. |
| 2 | // |
| 3 | // ── Why this file exists ─────────────────────────────────────────────────────────────────────── |
| 4 | // |
| 5 | // `IdentityRegistry` is moving from "an autobase" to "a log, engine selectable". The port is |
| 6 | // near-mechanical (the apply body, every op handler and every read method are engine-independent), |
| 7 | // which is exactly the kind of change that looks finished while being subtly wrong. So the autobase |
| 8 | // arm is a CONTROL: it runs today's shipping code path with these same assertions, and it must be |
| 9 | // green before the autobee arm means anything. An assertion that only ever ran against the new |
| 10 | // engine proves the new engine agrees with itself. |
| 11 | // |
| 12 | // This is the methodology the autobee spike used on pgh's NameRegistry, and it is the reason that |
| 13 | // spike's result was trustworthy. |
| 14 | // |
| 15 | // ── What is asserted, and what deliberately is not ──────────────────────────────────────────── |
| 16 | // |
| 17 | // BEHAVIOUR, never view bytes. Two things follow from that choice: |
| 18 | // |
| 19 | // 1. It is the contract consumers actually depend on. pgv3, hypernote, pear-git and hyper-booking |
| 20 | // call these methods; none of them reads the view's bytes. |
| 21 | // 2. ⛔ A byte-for-byte comparison would be invalid TODAY, on autobase alone, because |
| 22 | // `identity-apply.js` stamps `Date.now()` at ~16 sites inside apply. Apply is replayed per |
| 23 | // peer, so those bytes already differ between two peers replaying identical ops. That is a |
| 24 | // real pre-existing defect (test/apply-determinism.test.js catches two instances of it) and it |
| 25 | // is NOT this file's business — but it does mean "same bytes" is not an available oracle. |
| 26 | // |
| 27 | // The assertions below are chosen to exercise the whole surface a view shim has to cover: |
| 28 | // simple put/get, a secondary index, a uniqueness conflict, a range scan, and a tombstone. |
| 29 | |
| 30 | import test from 'brittle' |
| 31 | import Corestore from 'corestore' |
| 32 | import Hyperbee from 'hyperbee' |
| 33 | import path from '#node:path' |
| 34 | import os from '#node:os' |
| 35 | import crypto from 'hypercore-crypto' |
| 36 | import { IdentityRegistry } from '../lib/auth/identity-registry.js' |
| 37 | |
| 38 | // Engines under test. autobase FIRST and always — it is the control, and a run where only the |
| 39 | // autobee arm executed is a run that proved nothing. |
| 40 | const ENGINES = ['autobase', 'autobee'] |
| 41 | |
| 42 | async function open (engine) { |
| 43 | const dir = path.join(os.tmpdir(), 'hyper-identity-parity-' + crypto.randomBytes(8).toString('hex')) |
| 44 | const store = new Corestore(dir) |
| 45 | await store.ready() |
| 46 | const db = new Hyperbee(store.get({ name: 'legacy-db' }), { keyEncoding: 'utf-8', valueEncoding: 'json' }) |
| 47 | await db.ready() |
| 48 | |
| 49 | const registry = new IdentityRegistry(store, { db, engine }) |
| 50 | await registry.ready() |
| 51 | |
| 52 | // ⛔ Without this, the harness is a FALSE GREEN. An `engine` option that does not exist yet is |
| 53 | // silently ignored, both arms build an autobase, both pass, and the suite proves that autobase |
| 54 | // agrees with autobase. The registry must SAY which engine it constructed. |
| 55 | if (registry.engine !== engine) { |
| 56 | throw new Error('asked for engine "' + engine + '" and got "' + registry.engine + '"') |
| 57 | } |
| 58 | |
| 59 | return { |
| 60 | registry, |
| 61 | async cleanup () { |
| 62 | await registry.close() |
| 63 | await db.close() |
| 64 | await store.close() |
| 65 | } |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | /** |
| 70 | * The contract. Every assertion here must hold identically on both engines. |
| 71 | * |
| 72 | * Kept as one function rather than one test per behaviour so the two arms cannot drift: adding an |
| 73 | * assertion for one engine adds it for the other, structurally. |
| 74 | */ |
| 75 | async function contract (t, registry) { |
| 76 | // ── a record round-trips through the view's encoding ──────────────────────────────────────── |
| 77 | // |
| 78 | // The name carries non-ASCII and the picture is a long opaque string, because the autobee view is |
| 79 | // raw Buffers where the autobase view had keyEncoding:'utf-8'/valueEncoding:'json'. A shim that |
| 80 | // got the encoding subtly wrong passes for 'Alice' and fails here. |
| 81 | const alice = await registry.createUser({ name: 'Ada Lovelace — éè你好' }) |
| 82 | t.ok(alice.userId, 'createUser returns a userId') |
| 83 | t.is(alice.name, 'Ada Lovelace — éè你好', 'unicode survives the view encoding') |
| 84 | t.ok(alice.globalPermissions.includes('admin'), 'the first user is promoted to admin, in apply') |
| 85 | |
| 86 | const fetched = await registry.getUser(alice.userId) |
| 87 | t.is(fetched.userId, alice.userId, 'and reads back by key') |
| 88 | t.is(fetched.name, alice.name) |
| 89 | |
| 90 | // ── a secondary index ─────────────────────────────────────────────────────────────────────── |
| 91 | await registry.registerIdentity({ |
| 92 | identityId: 'ident-1', method: 'web-identity', publicKey: 'pk-ada' |
| 93 | }) |
| 94 | const ident = await registry.getIdentityByPublicKey('pk-ada') |
| 95 | t.ok(ident, 'an identity resolves by public key, not just by id') |
| 96 | t.is(ident.method, 'web-identity') |
| 97 | |
| 98 | await registry.linkIdentityToUser('ident-1', alice.userId, { verified: true }) |
| 99 | const byIdent = await registry.getUserByIdentity('pk-ada') |
| 100 | t.is(byIdent && byIdent.userId, alice.userId, 'and the link resolves back to the user') |
| 101 | |
| 102 | // ── a uniqueness conflict ─────────────────────────────────────────────────────────────────── |
| 103 | // |
| 104 | // The conflict path reads the index INSIDE the write, so it exercises read-after-write within one |
| 105 | // logical operation — the thing a batching shim is most likely to break. |
| 106 | await registry.setUsername(alice.userId, 'ada') |
| 107 | const byName = await registry.getUserByUsername('ada') |
| 108 | t.is(byName && byName.userId, alice.userId, 'username resolves') |
| 109 | |
| 110 | const bob = await registry.createUser({ name: 'Grace' }) |
| 111 | await t.exception(registry.setUsername(bob.userId, 'ada'), /taken/i, 'a taken username is refused') |
| 112 | t.is((await registry.getUserByUsername('ada')).userId, alice.userId, 'and the incumbent is untouched') |
| 113 | |
| 114 | // ── the profile — the reason favela-peer wants this layer at all ──────────────────────────── |
| 115 | await registry.updateUserProfile(alice.userId, { name: 'Ada L.', profilePicture: 'a'.repeat(64) }) |
| 116 | const profiled = await registry.getUser(alice.userId) |
| 117 | t.is(profiled.name, 'Ada L.', 'the display name updates') |
| 118 | t.is(profiled.profilePicture, 'a'.repeat(64), 'and the picture is stored verbatim — an overlay oid') |
| 119 | |
| 120 | // ── a range scan ──────────────────────────────────────────────────────────────────────────── |
| 121 | // |
| 122 | // createReadStream over a prefix. hyperbee2 has no keyEncoding, so a shim must rebuild the range |
| 123 | // bounds itself; getting that wrong typically returns everything or nothing rather than erroring. |
| 124 | const users = await registry.listUsers() |
| 125 | const ids = users.map((u) => u.userId).sort() |
| 126 | t.is(ids.length, 2, 'the scan finds exactly the users written') |
| 127 | t.alike(ids, [alice.userId, bob.userId].sort(), 'and finds the right ones') |
| 128 | |
| 129 | // ── a tombstone ───────────────────────────────────────────────────────────────────────────── |
| 130 | t.absent(await registry.isRevoked('pk-ada'), 'not revoked to begin with') |
| 131 | await registry.revokeIdentity('pk-ada') |
| 132 | t.ok(await registry.isRevoked('pk-ada'), 'revocation reads through') |
| 133 | } |
| 134 | |
| 135 | for (const engine of ENGINES) { |
| 136 | test('[' + engine + '] the IdentityRegistry contract', async (t) => { |
| 137 | const { registry, cleanup } = await open(engine) |
| 138 | t.teardown(cleanup) |
| 139 | await contract(t, registry) |
| 140 | }) |
| 141 | } |
| 142 | |
| 143 | // ⛔ The one API break that fails SILENTLY, so it gets its own test. |
| 144 | // |
| 145 | // autobase takes `addWriter(key, { indexer })`. autobee takes |
| 146 | // `addWriter(key, { isIndexer = true, weight = isIndexer ? 2 : 1 })` — verified in autobee 2.1.0 |
| 147 | // `lib/apply-calls.js:33`. An unknown `indexer` key is IGNORED and `isIndexer` DEFAULTS TO TRUE, so |
| 148 | // a mechanical port silently grants every writer indexer standing. |
| 149 | // |
| 150 | // That is not a hypothetical: identity-registry.js:83 warns in its own comment against making every |
| 151 | // device an indexer, "which deadlocks during manifest migration when going from 2→3 indexers". |
| 152 | // |
| 153 | // So the default is asserted rather than inherited, on both engines. |
| 154 | for (const engine of ENGINES) { |
| 155 | test('[' + engine + '] a plain writer is NOT granted indexer standing', async (t) => { |
| 156 | const { registry, cleanup } = await open(engine) |
| 157 | t.teardown(cleanup) |
| 158 | |
| 159 | const key = crypto.keyPair().publicKey.toString('hex') |
| 160 | await registry.addWriter(key, { role: 'writer' }) |
| 161 | |
| 162 | // The view's audit row is engine-independent — it is written by apply, from the op. |
| 163 | const rec = await registry.view.get('writer:' + key) |
| 164 | t.ok(rec, 'the writer record landed') |
| 165 | t.is((rec.value || rec).role, 'writer', 'and it recorded the role it was given') |
| 166 | |
| 167 | // ⛔ The row is NOT the guard. It carries {addedAt, addedBy, role} and no indexer field, so |
| 168 | // asserting on it would pass whatever the engine actually granted. The real question is what |
| 169 | // standing the LOG gave the writer, which each engine spells differently — hence a normalized |
| 170 | // accessor, which the port owes. Red until it exists, deliberately. |
| 171 | t.is(await registry.isIndexerWriter(key), false, |
| 172 | 'a writer is not an indexer — the default must be pinned, never inherited from the engine') |
| 173 | }) |
| 174 | } |