| … | … | @@ -1,202 +0,0 @@ |
| 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 | | // ⚠ Keyed on the identityId, NOT the public key — `user-identity:<identityId>` |
| 100 | | // (identity-registry.js:214). They coincide in pgv3, where registerIdentity passes the root |
| 101 | | // pubkey as both, which is exactly why passing the wrong one reads as a library bug. |
| 102 | | const byIdent = await registry.getUserByIdentity('ident-1') |
| 103 | | t.is(byIdent && byIdent.userId, alice.userId, 'and the link resolves back to the user') |
| 104 | | |
| 105 | | // ── a uniqueness conflict ─────────────────────────────────────────────────────────────────── |
| 106 | | // |
| 107 | | // The conflict path reads the index INSIDE the write, so it exercises read-after-write within one |
| 108 | | // logical operation — the thing a batching shim is most likely to break. |
| 109 | | await registry.setUsername(alice.userId, 'ada') |
| 110 | | const byName = await registry.getUserByUsername('ada') |
| 111 | | t.is(byName && byName.userId, alice.userId, 'username resolves') |
| 112 | | |
| 113 | | const bob = await registry.createUser({ name: 'Grace' }) |
| 114 | | await t.exception(registry.setUsername(bob.userId, 'ada'), /taken/i, 'a taken username is refused') |
| 115 | | t.is((await registry.getUserByUsername('ada')).userId, alice.userId, 'and the incumbent is untouched') |
| 116 | | |
| 117 | | // ── the profile — the reason favela-peer wants this layer at all ──────────────────────────── |
| 118 | | await registry.updateUserProfile(alice.userId, { name: 'Ada L.', profilePicture: 'a'.repeat(64) }) |
| 119 | | const profiled = await registry.getUser(alice.userId) |
| 120 | | t.is(profiled.name, 'Ada L.', 'the display name updates') |
| 121 | | t.is(profiled.profilePicture, 'a'.repeat(64), 'and the picture is stored verbatim — an overlay oid') |
| 122 | | |
| 123 | | // ── a range scan ──────────────────────────────────────────────────────────────────────────── |
| 124 | | // |
| 125 | | // createReadStream over a prefix. hyperbee2 has no keyEncoding, so a shim must rebuild the range |
| 126 | | // bounds itself; getting that wrong typically returns everything or nothing rather than erroring. |
| 127 | | const users = await registry.listUsers() |
| 128 | | const ids = users.map((u) => u.userId).sort() |
| 129 | | t.is(ids.length, 2, 'the scan finds exactly the users written') |
| 130 | | t.alike(ids, [alice.userId, bob.userId].sort(), 'and finds the right ones') |
| 131 | | |
| 132 | | // ── a tombstone ───────────────────────────────────────────────────────────────────────────── |
| 133 | | t.absent(await registry.isRevoked('pk-ada'), 'not revoked to begin with') |
| 134 | | await registry.revokeIdentity('pk-ada') |
| 135 | | t.ok(await registry.isRevoked('pk-ada'), 'revocation reads through') |
| 136 | | } |
| 137 | | |
| 138 | | // An engine that cannot be constructed must FAIL, not abort. |
| 139 | | // |
| 140 | | // A throw out of `open()` kills the whole file, so every test after it silently never runs — the |
| 141 | | // report says nothing about them, which reads like coverage that does not exist. This turns an |
| 142 | | // unbuildable engine into one red line and lets the rest of the suite report. |
| 143 | | async function openOrFail (t, engine) { |
| 144 | | try { |
| 145 | | return await open(engine) |
| 146 | | } catch (err) { |
| 147 | | t.fail('cannot open the ' + engine + ' engine: ' + err.message) |
| 148 | | return null |
| 149 | | } |
| 150 | | } |
| 151 | | |
| 152 | | for (const engine of ENGINES) { |
| 153 | | test('[' + engine + '] the IdentityRegistry contract', async (t) => { |
| 154 | | const opened = await openOrFail(t, engine) |
| 155 | | if (!opened) return |
| 156 | | t.teardown(opened.cleanup) |
| 157 | | await contract(t, opened.registry) |
| 158 | | }) |
| 159 | | } |
| 160 | | |
| 161 | | // ⛔ The one API break that fails SILENTLY, so it gets its own test. |
| 162 | | // |
| 163 | | // The first version of this test asked the ENGINE whether a writer had ended up an indexer. That |
| 164 | | // was the wrong oracle, and its own positive control proved it: autobase leaves a granted indexer |
| 165 | | // in `pendingIndexers` until its core exists and acks, so a freshly granted key is in NEITHER list |
| 166 | | // and the accessor answered "no" to everything — including the case it was supposed to catch. A |
| 167 | | // test that cannot say YES cannot mean anything by NO. |
| 168 | | // |
| 169 | | // The defect is a TRANSLATION, so test the translation. `adaptHost` is the one function in each |
| 170 | | // engine package that knows its engine's spelling, and a spy sees exactly what the engine would |
| 171 | | // have been handed — with none of the writer-lifecycle noise that made the first oracle useless. |
| 172 | | |
| 173 | | import { adaptHost } from '../lib/auth/adapt-host.js' |
| 174 | | |
| 175 | | test('⛔ [autobase] adaptHost translates the core vocabulary, and pins the default', (t) => { |
| 176 | | const seen = [] |
| 177 | | const host = adaptHost({ |
| 178 | | addWriter: (key, opts) => { seen.push(['add', key, opts]) }, |
| 179 | | removeWriter: (key) => { seen.push(['remove', key]) } |
| 180 | | }) |
| 181 | | |
| 182 | | host.addWriter('k1', { isIndexer: true }) |
| 183 | | host.addWriter('k2', { isIndexer: false }) |
| 184 | | host.addWriter('k3', {}) |
| 185 | | host.removeWriter('k1') |
| 186 | | |
| 187 | | t.alike(seen[0], ['add', 'k1', { indexer: true }], 'isIndexer:true becomes autobase indexer:true') |
| 188 | | t.alike(seen[1], ['add', 'k2', { indexer: false }], 'and false stays false') |
| 189 | | |
| 190 | | // ⛔ The one that bites. autobee DEFAULTS isIndexer to TRUE, so an option that goes missing in |
| 191 | | // translation silently promotes every writer. The absent case must be explicit, never inherited. |
| 192 | | t.alike(seen[2], ['add', 'k3', { indexer: false }], 'an ABSENT flag is false, never the engine default') |
| 193 | | |
| 194 | | t.alike(seen[3], ['remove', 'k1'], 'removeWriter passes through') |
| 195 | | t.absent('isIndexer' in seen[0][2], 'the core spelling never reaches autobase') |
| 196 | | }) |
| 197 | | |
| 198 | | test('adaptHost leaves a host with no writer controls alone', (t) => { |
| 199 | | const bare = { somethingElse: 1 } |
| 200 | | t.is(adaptHost(bare), bare, 'nothing to translate, nothing wrapped') |
| 201 | | t.is(adaptHost(null), null) |
| 202 | | }) |