import test from 'brittle'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import Corestore from 'corestore'
import { NamespaceRegistry } from '../../lib/namespace-registry/namespace-registry.js'
import {
encodeOp, decodeOp, validateOp,
OP_CREATE_NAMESPACE, OP_ADD_REPO, OP_MOVE_REPO,
OP_ADD_MEMBER, OP_REMOVE_MEMBER,
OP_ADD_WRITER, OP_REMOVE_WRITER, OP_SET_META
} from '../../lib/namespace-registry/operations.js'
const tmpDir = path.join(os.tmpdir(), 'pear-git-test-nsreg-' + Date.now())
test('setup: create temp dir', async (t) => {
await fs.promises.mkdir(tmpDir, { recursive: true })
t.pass('temp dir created')
})
// ========================================
// Operations tests
// ========================================
test('operations: encode and decode roundtrip', async (t) => {
const op = { type: 'create-namespace', name: 'test-ns', nsType: 'group' }
const encoded = encodeOp(op)
t.ok(Buffer.isBuffer(encoded), 'encode returns Buffer')
const decoded = decodeOp(encoded)
t.is(decoded.type, 'create-namespace')
t.is(decoded.name, 'test-ns')
t.is(decoded.nsType, 'group')
})
test('operations: validate catches missing fields', async (t) => {
// Missing type
t.is(validateOp({}).valid, false)
t.is(validateOp({ type: 'unknown-op' }).valid, false)
// create-namespace needs name and nsType
t.is(validateOp({ type: OP_CREATE_NAMESPACE }).valid, false)
t.is(validateOp({ type: OP_CREATE_NAMESPACE, name: 'test' }).valid, false)
t.is(validateOp({ type: OP_CREATE_NAMESPACE, name: 'test', nsType: 'group' }).valid, true)
t.is(validateOp({ type: OP_CREATE_NAMESPACE, name: 'test', nsType: 'user' }).valid, true)
t.is(validateOp({ type: OP_CREATE_NAMESPACE, name: 'test', nsType: 'invalid' }).valid, false)
// add-repo needs namespaceName and repoName
t.is(validateOp({ type: OP_ADD_REPO }).valid, false)
t.is(validateOp({ type: OP_ADD_REPO, namespaceName: 'ns' }).valid, false)
t.is(validateOp({ type: OP_ADD_REPO, namespaceName: 'ns', repoName: 'repo' }).valid, true)
// move-repo needs repoName, fromNamespace, toNamespace
t.is(validateOp({ type: OP_MOVE_REPO, repoName: 'r' }).valid, false)
t.is(validateOp({ type: OP_MOVE_REPO, repoName: 'r', fromNamespace: 'a', toNamespace: 'b' }).valid, true)
// add-member needs namespaceName, memberId, role
t.is(validateOp({ type: OP_ADD_MEMBER, namespaceName: 'ns' }).valid, false)
t.is(validateOp({ type: OP_ADD_MEMBER, namespaceName: 'ns', memberId: 'u1', role: 'admin' }).valid, true)
t.is(validateOp({ type: OP_ADD_MEMBER, namespaceName: 'ns', memberId: 'u1', role: 'invalid' }).valid, false)
// add-writer needs peerId, serverKey, role
t.is(validateOp({ type: OP_ADD_WRITER }).valid, false)
t.is(validateOp({ type: OP_ADD_WRITER, peerId: 'p1', serverKey: 'abc', role: 'owner' }).valid, true)
// set-meta needs key
t.is(validateOp({ type: OP_SET_META }).valid, false)
t.is(validateOp({ type: OP_SET_META, key: 'foo' }).valid, true)
})
// ========================================
// Registry CRUD tests
// ========================================
test('registry: create new autobase', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-create'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
t.ok(registry.bootstrapKey, 'has bootstrap key')
t.ok(registry.bootstrapKeyHex, 'has bootstrap key hex')
t.is(registry.bootstrapKeyHex.length, 64, 'key is 32 bytes hex')
t.ok(registry.writable, 'is writable (creator)')
await registry.close()
await store.close()
})
test('registry: create and get namespace', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-crud'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
const ns = await registry.create('holepunchto', 'group', {
displayName: 'Holepunch',
description: 'Core repos'
})
t.ok(ns, 'namespace returned')
t.is(ns.name, 'holepunchto')
t.is(ns.displayName, 'Holepunch')
t.is(ns.description, 'Core repos')
t.is(ns.type, 'group')
t.ok(Array.isArray(ns.repos), 'repos is array')
t.is(ns.repos.length, 0, 'no repos initially')
// Get
const fetched = await registry.get('holepunchto')
t.is(fetched.name, 'holepunchto')
t.is(fetched.displayName, 'Holepunch')
// Not found
const missing = await registry.get('nonexistent')
t.is(missing, null)
await registry.close()
await store.close()
})
test('registry: create duplicate throws', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-dup'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
await registry.create('test-ns', 'group')
try {
await registry.create('test-ns', 'group')
t.fail('should have thrown')
} catch (err) {
t.ok(err.message.includes('already exists'))
}
await registry.close()
await store.close()
})
test('registry: validate name', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-validate'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
t.is(registry.validateName('valid-name').valid, true)
t.is(registry.validateName('a').valid, true)
t.is(registry.validateName('').valid, false)
t.is(registry.validateName('-invalid').valid, false)
t.is(registry.validateName('api').valid, false, 'reserved name')
t.is(registry.validateName('has--double').valid, false, 'consecutive hyphens')
await registry.close()
await store.close()
})
test('registry: update namespace', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-update'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
await registry.create('updatable', 'group', { description: 'original' })
const updated = await registry.update('updatable', {
description: 'updated desc',
displayName: 'Updated'
})
t.is(updated.description, 'updated desc')
t.is(updated.displayName, 'Updated')
t.ok(updated.updatedAt >= updated.createdAt)
await registry.close()
await store.close()
})
test('registry: delete empty namespace', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-delete'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
await registry.create('deletable', 'group')
await registry.delete('deletable')
const result = await registry.get('deletable')
t.is(result, null, 'namespace deleted')
await registry.close()
await store.close()
})
test('registry: cannot delete namespace with repos', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-del-fail'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
await registry.create('has-repos', 'group')
await registry.addRepo('has-repos', 'my-repo')
try {
await registry.delete('has-repos')
t.fail('should have thrown')
} catch (err) {
t.ok(err.message.includes('still has'))
}
await registry.close()
await store.close()
})
test('registry: list namespaces with filters', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-list'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
await registry.create('group1', 'group')
await registry.create('group2', 'group')
await registry.create('user1', 'user', { userId: 'u1' })
const all = await registry.list()
t.is(all.length, 3)
const groups = await registry.list({ type: 'group' })
t.is(groups.length, 2)
const users = await registry.list({ type: 'user' })
t.is(users.length, 1)
t.is(users[0].name, 'user1')
await registry.close()
await store.close()
})
// ========================================
// Repo membership tests
// ========================================
test('registry: add and remove repo', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-repo'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
await registry.create('mygroup', 'group')
const ns = await registry.addRepo('mygroup', 'hypercore')
t.ok(ns.repos.includes('hypercore'), 'repo added')
const ns2 = await registry.removeRepo('mygroup', 'hypercore')
t.ok(!ns2.repos.includes('hypercore'), 'repo removed')
await registry.close()
await store.close()
})
test('registry: add-repo deduplicates across namespaces', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-dedup'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
await registry.create('ns-a', 'group')
await registry.create('ns-b', 'group')
// Add to ns-a first
await registry.addRepo('ns-a', 'shared-repo')
let nsA = await registry.get('ns-a')
t.ok(nsA.repos.includes('shared-repo'), 'repo in ns-a')
// Move to ns-b — should remove from ns-a automatically
await registry.addRepo('ns-b', 'shared-repo')
nsA = await registry.get('ns-a')
const nsB = await registry.get('ns-b')
t.ok(!nsA.repos.includes('shared-repo'), 'repo removed from ns-a')
t.ok(nsB.repos.includes('shared-repo'), 'repo in ns-b')
await registry.close()
await store.close()
})
test('registry: move repo between namespaces', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-move'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
await registry.create('src', 'group')
await registry.create('dst', 'group')
await registry.addRepo('src', 'movable')
await registry.moveRepo('movable', 'src', 'dst')
const src = await registry.get('src')
const dst = await registry.get('dst')
t.ok(!src.repos.includes('movable'), 'removed from source')
t.ok(dst.repos.includes('movable'), 'added to destination')
await registry.close()
await store.close()
})
// ========================================
// Group member tests
// ========================================
test('registry: add and remove members', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-members'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
await registry.create('team', 'group')
// Add member
let ns = await registry.addMember('team', 'alice', 'admin')
t.is(ns.members.length, 1)
t.is(ns.members[0].userId, 'alice')
t.is(ns.members[0].role, 'admin')
// Add another member
ns = await registry.addMember('team', 'bob', 'member')
t.is(ns.members.length, 2)
// Upsert — update role
ns = await registry.addMember('team', 'bob', 'admin')
const bob = ns.members.find(m => m.userId === 'bob')
t.is(bob.role, 'admin', 'role updated')
t.is(ns.members.length, 2, 'still 2 members (upsert)')
// Remove member
ns = await registry.removeMember('team', 'alice')
t.is(ns.members.length, 1)
t.is(ns.members[0].userId, 'bob')
await registry.close()
await store.close()
})
test('registry: cannot add members to user namespace', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-no-member'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
await registry.create('person', 'user', { userId: 'u1' })
try {
await registry.addMember('person', 'someone', 'member')
t.fail('should have thrown')
} catch (err) {
t.ok(err.message.includes('group namespaces'))
}
await registry.close()
await store.close()
})
test('registry: getUserNamespaces', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-user-ns'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
await registry.create('team-a', 'group')
await registry.create('team-b', 'group')
await registry.create('user-alice', 'user', { userId: 'alice' })
await registry.addMember('team-a', 'alice', 'admin')
// alice is NOT in team-b
const aliceNs = await registry.getUserNamespaces('alice')
t.is(aliceNs.length, 2, 'alice in team-a + user-alice')
const names = aliceNs.map(n => n.name).sort()
t.ok(names.includes('team-a'))
t.ok(names.includes('user-alice'))
await registry.close()
await store.close()
})
// ========================================
// Writer management tests
// ========================================
test('registry: add and list writers', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-writers'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
const ownerKey = 'a'.repeat(64)
await registry.addWriter('peer-a', ownerKey, 'owner')
const writers = await registry.listWriters()
t.is(writers.length, 1)
t.is(writers[0].peerId, 'peer-a')
t.is(writers[0].role, 'owner')
t.is(writers[0].serverKey, ownerKey)
// Get single writer
const writer = await registry.getWriter('peer-a')
t.ok(writer)
t.is(writer.role, 'owner')
// Non-existent
const missing = await registry.getWriter('nonexistent')
t.is(missing, null)
await registry.close()
await store.close()
})
test('registry: remove writer', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-rm-writer'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
const key = 'b'.repeat(64)
await registry.addWriter('peer-b', key, 'writer')
let writers = await registry.listWriters()
t.is(writers.length, 1)
await registry.removeWriter('peer-b')
writers = await registry.listWriters()
t.is(writers.length, 0, 'writer removed')
await registry.close()
await store.close()
})
// ========================================
// Meta tests
// ========================================
test('registry: set and get meta', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-meta'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
await registry.setMeta('registryName', 'My Forge')
const val = await registry.getMeta('registryName')
t.is(val, 'My Forge')
// Overwrite
await registry.setMeta('registryName', 'Updated')
const val2 = await registry.getMeta('registryName')
t.is(val2, 'Updated')
// Non-existent
const missing = await registry.getMeta('nope')
t.is(missing, null)
await registry.close()
await store.close()
})
// ========================================
// Migration test
// ========================================
test('registry: migrateExistingRepos', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-migrate'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
const repos = [
{ name: 'hypercore', category: 'holepunchto' },
{ name: 'hyperbee', category: 'holepunchto' },
{ name: 'my-project', category: 'uncategorized' },
{ name: 'another', category: 'uncategorized' }
]
const result = await registry.migrateExistingRepos(repos)
t.ok(result.created >= 1, 'created namespace(s)')
t.ok(result.assigned >= 4, 'assigned repos')
const holepunchto = await registry.get('holepunchto')
t.ok(holepunchto, 'holepunchto namespace created')
t.ok(holepunchto.repos.includes('hypercore'))
t.ok(holepunchto.repos.includes('hyperbee'))
const local = await registry.get('local')
t.ok(local, 'local namespace exists')
t.ok(local.repos.includes('my-project'))
t.ok(local.repos.includes('another'))
// Idempotent — re-run should skip
const result2 = await registry.migrateExistingRepos(repos)
t.is(result2.skipped, 4, 'all skipped on re-run')
t.is(result2.created, 0, 'no new namespaces')
await registry.close()
await store.close()
})
// ========================================
// Persistence test
// ========================================
test('registry: reopen from bootstrap key', async (t) => {
const storePath = path.join(tmpDir, 'store-reopen')
const store1 = new Corestore(storePath)
const registry1 = new NamespaceRegistry({ corestore: store1, bootstrapKey: null })
await registry1.ready()
const key = registry1.bootstrapKeyHex
// Write some data
await registry1.create('persisted', 'group', { description: 'will persist' })
await registry1.addRepo('persisted', 'hypercore')
await registry1.setMeta('version', '1.0')
await registry1.close()
await store1.close()
// Reopen with same store path and bootstrap key
const store2 = new Corestore(storePath)
const registry2 = new NamespaceRegistry({ corestore: store2, bootstrapKey: key })
await registry2.ready()
const ns = await registry2.get('persisted')
t.ok(ns, 'namespace persisted')
t.is(ns.description, 'will persist')
t.ok(ns.repos.includes('hypercore'), 'repos persisted')
const meta = await registry2.getMeta('version')
t.is(meta, '1.0', 'meta persisted')
await registry2.close()
await store2.close()
})
// ========================================
// ensureDefaultNamespace test
// ========================================
test('registry: ensureDefaultNamespace', async (t) => {
const store = new Corestore(path.join(tmpDir, 'store-default'))
const registry = new NamespaceRegistry({ corestore: store, bootstrapKey: null })
await registry.ready()
// First call creates
const ns1 = await registry.ensureDefaultNamespace('local')
t.is(ns1.name, 'local')
t.is(ns1.displayName, 'Local')
// Second call returns existing
const ns2 = await registry.ensureDefaultNamespace('local')
t.is(ns2.name, 'local')
t.is(ns2.createdAt, ns1.createdAt, 'same namespace')
await registry.close()
await store.close()
})
// ========================================
// Cleanup
// ========================================
test('cleanup: remove temp dir', async (t) => {
await fs.promises.rm(tmpDir, { recursive: true, force: true })
t.pass('cleaned up')
})