OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / test / unit / namespace-registry.test.js

558 lines · javascript ref:
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')
})