| … | … | @@ -0,0 +1,155 @@ |
| 1 | // home.get — every identity has ONE home room (hyper-tps docs/home-world-design.md §2, |
| 2 | // gate H2). The daemon owns the pointer: identities.json entry `homeRoomId`. |
| 3 | // |
| 4 | // 1. a fresh identity: two CONCURRENT home.get calls create ONE room |
| 5 | // 2. a later call returns the same room, created:false |
| 6 | // 3. the pointer is on disk (identities.json), so it survives a restart |
| 7 | // 4. another identity gets its own home; switching back returns the first |
| 8 | // 5. a pointer to a room that is gone -> HOME_ROOM_MISSING, and NO new room |
| 9 | // |
| 10 | // SAFETY: fresh tmp storage; ~/.config/keet-cli is never opened. RoomMeta's legacy |
| 11 | // migration renames files under os.homedir(), so this REFUSES to run unless HOME |
| 12 | // is a scratch dir too: |
| 13 | // |
| 14 | // cd hyper-fleeky/packages/keet-daemon && HOME=$(mktemp -d) bare test/home-get.test.js |
| 15 | |
| 16 | import fs from 'bare-fs' |
| 17 | import path from 'bare-path' |
| 18 | import os from 'bare-os' |
| 19 | import Pipe from 'bare-pipe' |
| 20 | import FramedStream from 'framed-stream' |
| 21 | import { startDaemon } from '../index.js' |
| 22 | |
| 23 | let exitCode = 0 |
| 24 | const failures = [] |
| 25 | |
| 26 | function ok (cond, msg) { |
| 27 | if (cond) console.log(' ok ' + msg) |
| 28 | else { console.log(' FAIL ' + msg); exitCode = 1; failures.push(msg) } |
| 29 | } |
| 30 | |
| 31 | function rmrf (dir) { |
| 32 | try { fs.rmSync(dir, { recursive: true, force: true }) } catch {} |
| 33 | } |
| 34 | |
| 35 | class TestClient { |
| 36 | constructor (socketPath) { |
| 37 | this._socketPath = socketPath |
| 38 | this._nextId = 1 |
| 39 | this._pending = new Map() |
| 40 | } |
| 41 | connect () { |
| 42 | return new Promise((resolve, reject) => { |
| 43 | const pipe = new Pipe(this._socketPath) |
| 44 | pipe.once('error', reject) |
| 45 | pipe.once('connect', () => { |
| 46 | pipe.removeListener('error', reject) |
| 47 | this._pipe = pipe |
| 48 | const FS = FramedStream.default || FramedStream |
| 49 | this._framed = new FS(pipe) |
| 50 | this._framed.on('data', (buf) => { |
| 51 | let msg |
| 52 | try { msg = JSON.parse(buf.toString('utf-8')) } catch { return } |
| 53 | const p = this._pending.get(msg.id) |
| 54 | if (!p) return |
| 55 | this._pending.delete(msg.id) |
| 56 | if (msg.ok === false || msg.error) p.reject(new Error(msg.error || 'RPC failed')) |
| 57 | else p.resolve(msg.data !== undefined ? msg.data : msg) |
| 58 | }) |
| 59 | resolve() |
| 60 | }) |
| 61 | }) |
| 62 | } |
| 63 | call (method, params = {}, timeoutMs = 60000) { |
| 64 | const id = this._nextId++ |
| 65 | return new Promise((resolve, reject) => { |
| 66 | const timer = setTimeout(() => { |
| 67 | this._pending.delete(id) |
| 68 | reject(new Error('RPC timeout: ' + method)) |
| 69 | }, timeoutMs) |
| 70 | this._pending.set(id, { |
| 71 | resolve: (v) => { clearTimeout(timer); resolve(v) }, |
| 72 | reject: (e) => { clearTimeout(timer); reject(e) } |
| 73 | }) |
| 74 | this._framed.write(Buffer.from(JSON.stringify({ id, method, params }), 'utf-8')) |
| 75 | }) |
| 76 | } |
| 77 | close () { |
| 78 | try { this._framed && this._framed.destroy() } catch {} |
| 79 | try { this._pipe && this._pipe.destroy() } catch {} |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | function readRegistry (baseDir) { |
| 84 | return JSON.parse(fs.readFileSync(path.join(baseDir, 'identities.json'), 'utf-8')) |
| 85 | } |
| 86 | |
| 87 | async function main () { |
| 88 | const home = os.homedir() |
| 89 | if (!home.startsWith(os.tmpdir())) { |
| 90 | console.log('REFUSING: HOME=' + home + ' is not under ' + os.tmpdir() + '. Run with HOME=$(mktemp -d).') |
| 91 | Bare.exit(2) |
| 92 | } |
| 93 | const baseDir = path.join(os.tmpdir(), 'kd-home-' + Date.now().toString(36)) |
| 94 | fs.mkdirSync(baseDir, { recursive: true }) |
| 95 | const socketPath = path.join(baseDir, 'd.sock') |
| 96 | console.log('[test] baseDir = ' + baseDir) |
| 97 | |
| 98 | const daemon = await startDaemon({ storage: baseDir, socketPath, pidFile: path.join(baseDir, 'd.pid') }) |
| 99 | const client = new TestClient(socketPath) |
| 100 | await client.connect() |
| 101 | |
| 102 | // 1. concurrent first calls on a fresh identity -> one room |
| 103 | const [a, b] = await Promise.all([client.call('home.get'), client.call('home.get')]) |
| 104 | ok(typeof a.roomId === 'string' && a.roomId.length > 0, 'home.get returns a roomId: ' + a.roomId) |
| 105 | ok(a.roomId === b.roomId, 'two concurrent calls agree on one room') |
| 106 | ok([a.created, b.created].filter(Boolean).length === 1, 'exactly one of them created it; got ' + a.created + '/' + b.created) |
| 107 | const rooms1 = await client.call('rooms') |
| 108 | ok(rooms1.filter(r => r.roomId === a.roomId).length === 1 && rooms1.length === 1, 'one room exists; got ' + rooms1.length) |
| 109 | |
| 110 | // 2. a later call: same room, not created |
| 111 | const c = await client.call('home.get') |
| 112 | ok(c.roomId === a.roomId && c.created === false, 'later call returns it with created:false') |
| 113 | |
| 114 | // 3. on disk |
| 115 | const reg = readRegistry(baseDir) |
| 116 | const entry0 = reg.identities.find(e => e.id === reg.activeId) |
| 117 | ok(entry0 && entry0.homeRoomId === a.roomId, 'identities.json holds homeRoomId for ' + reg.activeId) |
| 118 | |
| 119 | // 4. another identity has its own home; switching back returns the first |
| 120 | const created = await client.call('identity-manager.create', { name: 'home-test-2' }) |
| 121 | await client.call('identity-manager.switch', { id: created.id }, 60000) |
| 122 | const d = await client.call('home.get') |
| 123 | ok(d.created === true && d.roomId !== a.roomId, created.id + ' gets its own home: ' + d.roomId) |
| 124 | await client.call('identity-manager.switch', { id: 'identity-0' }, 60000) |
| 125 | const e = await client.call('home.get') |
| 126 | ok(e.roomId === a.roomId && e.created === false, 'switching back returns the first home') |
| 127 | |
| 128 | // 5. a pointer to a room that is gone: error, and no new room |
| 129 | const live = daemon.registry.getActive() |
| 130 | const realHome = live.homeRoomId |
| 131 | live.homeRoomId = 'y'.repeat(52) // well-formed z32 length, no such room |
| 132 | const before = (await client.call('rooms')).length |
| 133 | let err = null |
| 134 | try { await client.call('home.get') } catch (x) { err = x } |
| 135 | ok(err && /HOME_ROOM_MISSING/.test(err.message), 'missing room -> HOME_ROOM_MISSING; got ' + (err ? err.message : 'no error')) |
| 136 | const after = (await client.call('rooms')).length |
| 137 | ok(after === before, 'no new room created on a missing home (' + before + ' -> ' + after + ')') |
| 138 | ok(daemon.registry.getActive().homeRoomId === 'y'.repeat(52), 'the pointer is left as it was for someone to look at') |
| 139 | live.homeRoomId = realHome |
| 140 | |
| 141 | client.close() |
| 142 | try { await daemon.router.core.close() } catch {} |
| 143 | try { await daemon.core.close() } catch {} |
| 144 | try { await daemon.uds.stop() } catch {} |
| 145 | rmrf(baseDir) |
| 146 | |
| 147 | console.log('') |
| 148 | console.log(exitCode === 0 ? 'ALL PASSED' : failures.length + ' FAILED:\n - ' + failures.join('\n - ')) |
| 149 | Bare.exit(exitCode) |
| 150 | } |
| 151 | |
| 152 | main().catch(err => { |
| 153 | console.error('uncaught:', err && err.stack ? err.stack : err) |
| 154 | Bare.exit(1) |
| 155 | }) |