OverviewFilesCommitsIssuesDocsReleasesPeers

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

698 lines · javascript ref:
/**
 * Unit tests for lib/auth/identity-registry.js
 *
 * Tests the 5-identity-type linking system:
 * - web-identity (public key based)
 * - ssh-key (SSH fingerprint based)
 * - noise-key (ANProto Ed25519 Noise key)
 * - keet-identity (Keet HD-derived key)
 * - Token-based (bearer tokens)
 *
 * Core capability: linking multiple identity types to a single user.
 *
 * Now backed by Autobase + Hyperbee (multi-writer).
 */

import test from 'brittle'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import Corestore from 'corestore'
import { IdentityRegistry } from '../../lib/auth/identity-registry.js'

const tmpDir = path.join(os.tmpdir(), 'pear-git-test-idreg-' + Date.now())
let storeIdx = 0

// Helper to create test registry with unique storage
async function createTestRegistry () {
  const storePath = path.join(tmpDir, 'store-' + (storeIdx++))
  const store = new Corestore(storePath)
  const registry = new IdentityRegistry({ corestore: store })
  await registry.ready()
  return registry
}

test('setup: create temp dir', async (t) => {
  await fs.promises.mkdir(tmpDir, { recursive: true })
  t.pass('temp dir created')
})

// ========================================
// User CRUD
// ========================================

test('createUser - creates user with defaults', async (t) => {
  const registry = await createTestRegistry()

  const user = await registry.createUser()

  t.ok(user.userId, 'should have userId')
  t.is(user.name, 'Anonymous')
  t.is(user.email, null)
  t.alike(user.globalPermissions, ['read'])
  t.alike(user.identities, [])
  t.ok(user.createdAt)

  await registry.close()
})

test('createUser - accepts custom fields', async (t) => {
  const registry = await createTestRegistry()

  const user = await registry.createUser({
    name: 'Alice',
    email: 'alice@example.com',
    permissions: ['read', 'write', 'admin']
  })

  t.is(user.name, 'Alice')
  t.is(user.email, 'alice@example.com')
  t.alike(user.globalPermissions, ['read', 'write', 'admin'])

  await registry.close()
})

test('getUser - retrieves stored user', async (t) => {
  const registry = await createTestRegistry()

  const created = await registry.createUser({ name: 'Bob' })
  const fetched = await registry.getUser(created.userId)

  t.is(fetched.name, 'Bob')
  t.is(fetched.userId, created.userId)

  await registry.close()
})

test('getUser - returns undefined for unknown user', async (t) => {
  const registry = await createTestRegistry()

  const result = await registry.getUser('nonexistent')
  t.is(result, undefined)

  await registry.close()
})

// ========================================
// Identity creation (all 5 types)
// ========================================

test('createIdentity - web-identity with pubkey lookup', async (t) => {
  const registry = await createTestRegistry()

  const identity = await registry.createIdentity({
    identityId: 'web-abc123',
    method: 'web-identity',
    credentials: { publicKey: 'pk_abc123' }
  })

  t.is(identity.method, 'web-identity')

  // Reverse lookup should work
  const found = await registry.getIdentityByPublicKey('pk_abc123')
  t.is(found.identityId, 'web-abc123')

  await registry.close()
})

test('createIdentity - ssh-key with fingerprint lookup', async (t) => {
  const registry = await createTestRegistry()

  const identity = await registry.createIdentity({
    identityId: 'ssh-def456',
    method: 'ssh-key',
    credentials: { fingerprint: 'SHA256:abc123def456' }
  })

  t.is(identity.method, 'ssh-key')

  // SSH lookup via view
  await registry._base.update()
  const entry = await registry._base.view.get('sshkey:SHA256:abc123def456')
  t.is(entry.value.identityId, 'ssh-def456')

  await registry.close()
})

test('createIdentity - noise-key (ANProto) with noisekey lookup', async (t) => {
  const registry = await createTestRegistry()

  const noiseKeyHex = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2'

  const identity = await registry.createIdentity({
    identityId: 'noise-ghi789',
    method: 'noise-key',
    credentials: { publicKey: noiseKeyHex }
  })

  t.is(identity.method, 'noise-key')

  // Noise key lookup
  const found = await registry.getIdentityByNoiseKey(noiseKeyHex)
  t.is(found.identityId, 'noise-ghi789')

  await registry.close()
})

test('createIdentity - keet-identity with pubkey lookup', async (t) => {
  const registry = await createTestRegistry()

  const keetPubKey = 'keet_pubkey_derived_from_bip39_mnemonic'

  const identity = await registry.createIdentity({
    identityId: 'keet-jkl012',
    method: 'keet-identity',
    credentials: { publicKey: keetPubKey }
  })

  t.is(identity.method, 'keet-identity')

  // Public key lookup (shared with web-identity)
  const found = await registry.getIdentityByPublicKey(keetPubKey)
  t.is(found.identityId, 'keet-jkl012')

  await registry.close()
})

// ========================================
// Identity linking (core capability)
// ========================================

test('linkIdentity - links identity to user', async (t) => {
  const registry = await createTestRegistry()

  const user = await registry.createUser({ name: 'Alice' })
  await registry.createIdentity({
    identityId: 'web-001',
    method: 'web-identity',
    credentials: { publicKey: 'pk_alice' }
  })

  const result = await registry.linkIdentity('web-001', user.userId)

  t.is(result.identity.userId, user.userId)
  t.ok(result.user.identities.includes('web-001'))

  await registry.close()
})

test('linkIdentity - multiple identity types to same user', async (t) => {
  const registry = await createTestRegistry()

  const user = await registry.createUser({ name: 'Bob' })

  // Create 4 different identity types
  await registry.createIdentity({
    identityId: 'web-bob',
    method: 'web-identity',
    credentials: { publicKey: 'pk_bob' }
  })
  await registry.createIdentity({
    identityId: 'ssh-bob',
    method: 'ssh-key',
    credentials: { fingerprint: 'SHA256:bobfingerprint' }
  })
  await registry.createIdentity({
    identityId: 'noise-bob',
    method: 'noise-key',
    credentials: { publicKey: 'noise_bob_hex_key' }
  })
  await registry.createIdentity({
    identityId: 'keet-bob',
    method: 'keet-identity',
    credentials: { publicKey: 'keet_bob_pubkey' }
  })

  // Link all to same user
  await registry.linkIdentity('web-bob', user.userId)
  await registry.linkIdentity('ssh-bob', user.userId)
  await registry.linkIdentity('noise-bob', user.userId)
  await registry.linkIdentity('keet-bob', user.userId)

  // User should have all 4 identities
  const updatedUser = await registry.getUser(user.userId)
  t.is(updatedUser.identities.length, 4)

  // All reverse lookups should resolve to same user
  const fromWeb = await registry.getUserByIdentity('web-bob')
  const fromNoise = await registry.getUserByIdentity('noise-bob')
  const fromKeet = await registry.getUserByIdentity('keet-bob')

  t.is(fromWeb.userId, user.userId)
  t.is(fromNoise.userId, user.userId)
  t.is(fromKeet.userId, user.userId)

  await registry.close()
})

test('linkIdentity - duplicate link is idempotent', async (t) => {
  const registry = await createTestRegistry()

  const user = await registry.createUser({ name: 'Carol' })
  await registry.createIdentity({
    identityId: 'web-carol',
    method: 'web-identity',
    credentials: { publicKey: 'pk_carol' }
  })

  await registry.linkIdentity('web-carol', user.userId)
  await registry.linkIdentity('web-carol', user.userId) // duplicate

  const updatedUser = await registry.getUser(user.userId)
  // Should not add duplicate
  const count = updatedUser.identities.filter(id => id === 'web-carol').length
  t.is(count, 1)

  await registry.close()
})

test('linkIdentity - throws for nonexistent identity', async (t) => {
  const registry = await createTestRegistry()

  const user = await registry.createUser({ name: 'Dan' })

  try {
    await registry.linkIdentity('nonexistent', user.userId)
    t.fail('should have thrown')
  } catch (err) {
    t.ok(err.message.includes('not found'))
  }

  await registry.close()
})

// ========================================
// getUserByIdentity
// ========================================

test('getUserByIdentity - returns null for unlinked identity', async (t) => {
  const registry = await createTestRegistry()

  await registry.createIdentity({
    identityId: 'orphan-001',
    method: 'web-identity',
    credentials: { publicKey: 'pk_orphan' }
  })

  const user = await registry.getUserByIdentity('orphan-001')
  t.is(user, null)

  await registry.close()
})

test('getUserByIdentity - returns null for unknown identity', async (t) => {
  const registry = await createTestRegistry()

  const user = await registry.getUserByIdentity('does-not-exist')
  t.is(user, null)

  await registry.close()
})

// ========================================
// autoRegister
// ========================================

test('autoRegister - creates user + identity + links them', async (t) => {
  const registry = await createTestRegistry()

  const result = await registry.autoRegister({
    identityId: 'auto-web-001',
    method: 'web-identity',
    name: 'New Visitor',
    credentials: { publicKey: 'pk_visitor' }
  })

  t.ok(result.user.userId)
  t.is(result.user.name, 'New Visitor')
  t.ok(result.user.identities.includes('auto-web-001'))
  t.is(result.identity.userId, result.user.userId)

  // Reverse lookup works
  const found = await registry.getUserByIdentity('auto-web-001')
  t.is(found.userId, result.user.userId)

  await registry.close()
})

// ========================================
// unlinkIdentity
// ========================================

test('unlinkIdentity - removes identity from user', async (t) => {
  const registry = await createTestRegistry()

  const user = await registry.createUser({ name: 'Eve' })
  await registry.createIdentity({
    identityId: 'web-eve',
    method: 'web-identity',
    credentials: { publicKey: 'pk_eve' }
  })
  await registry.linkIdentity('web-eve', user.userId)

  // Verify linked
  const linked = await registry.getUserByIdentity('web-eve')
  t.is(linked.userId, user.userId)

  // Unlink
  await registry.unlinkIdentity('web-eve')

  // Identity should no longer resolve to user
  const unlinked = await registry.getUserByIdentity('web-eve')
  t.is(unlinked, null)

  // User should no longer list the identity
  const updatedUser = await registry.getUser(user.userId)
  t.not(updatedUser.identities.includes('web-eve'))

  await registry.close()
})

// ========================================
// listUserIdentities
// ========================================

test('listUserIdentities - lists all linked identities', async (t) => {
  const registry = await createTestRegistry()

  const user = await registry.createUser({ name: 'Frank' })
  await registry.createIdentity({ identityId: 'web-frank', method: 'web-identity', credentials: { publicKey: 'pk_frank' } })
  await registry.createIdentity({ identityId: 'ssh-frank', method: 'ssh-key', credentials: { fingerprint: 'SHA256:frank' } })
  await registry.linkIdentity('web-frank', user.userId)
  await registry.linkIdentity('ssh-frank', user.userId)

  const identities = await registry.listUserIdentities(user.userId)
  t.is(identities.length, 2)

  const methods = identities.map(i => i.method).sort()
  t.alike(methods, ['ssh-key', 'web-identity'])

  await registry.close()
})

// ========================================
// Revocation
// ========================================

test('revokeIdentity - marks identity as revoked', async (t) => {
  const registry = await createTestRegistry()

  await registry.createIdentity({
    identityId: 'web-revoke',
    method: 'web-identity',
    credentials: { publicKey: 'pk_revoke' }
  })

  await registry.revokeIdentity('web-revoke', { reason: 'compromised' })

  const isRevoked = await registry.isRevoked('web-revoke')
  t.ok(isRevoked)

  const identity = await registry.getIdentity('web-revoke')
  t.ok(identity.revoked)
  t.is(identity.revocationReason, 'compromised')

  await registry.close()
})

test('restoreIdentity - clears revocation', async (t) => {
  const registry = await createTestRegistry()

  await registry.createIdentity({
    identityId: 'web-restore',
    method: 'web-identity',
    credentials: { publicKey: 'pk_restore' }
  })

  await registry.revokeIdentity('web-restore')
  t.ok(await registry.isRevoked('web-restore'))

  await registry.restoreIdentity('web-restore')
  t.not(await registry.isRevoked('web-restore'))

  await registry.close()
})

test('listRevokedIdentities - lists all revoked', async (t) => {
  const registry = await createTestRegistry()

  await registry.createIdentity({ identityId: 'r1', method: 'web-identity', credentials: { publicKey: 'pk_r1' } })
  await registry.createIdentity({ identityId: 'r2', method: 'noise-key', credentials: { publicKey: 'noise_r2' } })
  await registry.createIdentity({ identityId: 'r3', method: 'ssh-key', credentials: { fingerprint: 'SHA256:r3' } })

  await registry.revokeIdentity('r1')
  await registry.revokeIdentity('r3')

  const revoked = await registry.listRevokedIdentities()
  t.is(revoked.length, 2)
  const ids = revoked.map(r => r.identityId).sort()
  t.alike(ids, ['r1', 'r3'])

  await registry.close()
})

// ========================================
// Permissions
// ========================================

test('upgradePermissions - adds repo-specific permissions', async (t) => {
  const registry = await createTestRegistry()

  const user = await registry.createUser({ name: 'Grace' })
  await registry.upgradePermissions(user.userId, 'my-repo', ['write', 'admin'])

  const updated = await registry.getUser(user.userId)
  const repo = updated.repos.find(r => r.name === 'my-repo')
  t.ok(repo)
  t.ok(repo.permissions.includes('write'))
  t.ok(repo.permissions.includes('admin'))

  await registry.close()
})

test('upgradePermissions - merges with existing', async (t) => {
  const registry = await createTestRegistry()

  const user = await registry.createUser({ name: 'Heidi' })
  await registry.upgradePermissions(user.userId, '*', ['write'])

  // Global wildcard already had 'read' from createUser defaults
  const updated = await registry.getUser(user.userId)
  const global = updated.repos.find(r => r.name === '*')
  t.ok(global.permissions.includes('read'))
  t.ok(global.permissions.includes('write'))

  await registry.close()
})

// ========================================
// Account merging
// ========================================

test('mergeAccounts - transfers identities and permissions', async (t) => {
  const registry = await createTestRegistry()

  // Primary user with web identity
  const primary = await registry.createUser({ name: 'Ivan', permissions: ['read', 'write'] })
  await registry.createIdentity({ identityId: 'web-ivan', method: 'web-identity', credentials: { publicKey: 'pk_ivan' } })
  await registry.linkIdentity('web-ivan', primary.userId)

  // Secondary user with SSH + Noise identities
  const secondary = await registry.createUser({ name: 'Ivan-SSH', permissions: ['read'] })
  await registry.createIdentity({ identityId: 'ssh-ivan', method: 'ssh-key', credentials: { fingerprint: 'SHA256:ivan' } })
  await registry.createIdentity({ identityId: 'noise-ivan', method: 'noise-key', credentials: { publicKey: 'noise_ivan' } })
  await registry.linkIdentity('ssh-ivan', secondary.userId)
  await registry.linkIdentity('noise-ivan', secondary.userId)

  // Merge secondary into primary
  const result = await registry.mergeAccounts(primary.userId, secondary.userId)

  // Primary should now have all 3 identities
  t.is(result.primary.identities.length, 3)
  t.ok(result.primary.identities.includes('web-ivan'))
  t.ok(result.primary.identities.includes('ssh-ivan'))
  t.ok(result.primary.identities.includes('noise-ivan'))

  // Stats
  t.is(result.stats.identitiesTransferred, 2)

  // Secondary should be marked as merged
  t.is(result.merged.mergedInto, primary.userId)
  t.alike(result.merged.identities, [])

  // All identity lookups resolve to primary
  const fromWeb = await registry.getUserByIdentity('web-ivan')
  const fromSSH = await registry.getUserByIdentity('ssh-ivan')
  const fromNoise = await registry.getUserByIdentity('noise-ivan')
  t.is(fromWeb.userId, primary.userId)
  t.is(fromSSH.userId, primary.userId)
  t.is(fromNoise.userId, primary.userId)

  await registry.close()
})

test('mergeAccounts - skips revoked identities', async (t) => {
  const registry = await createTestRegistry()

  const primary = await registry.createUser({ name: 'Jane' })
  const secondary = await registry.createUser({ name: 'Jane-Old' })

  await registry.createIdentity({ identityId: 'web-jane-old', method: 'web-identity', credentials: { publicKey: 'pk_jane_old' } })
  await registry.createIdentity({ identityId: 'ssh-jane-old', method: 'ssh-key', credentials: { fingerprint: 'SHA256:jane_old' } })
  await registry.linkIdentity('web-jane-old', secondary.userId)
  await registry.linkIdentity('ssh-jane-old', secondary.userId)

  // Revoke one identity
  await registry.revokeIdentity('web-jane-old', { reason: 'compromised key' })

  // Merge
  const result = await registry.mergeAccounts(primary.userId, secondary.userId)

  // Only the non-revoked identity should be transferred
  t.is(result.stats.identitiesTransferred, 1)
  t.ok(result.primary.identities.includes('ssh-jane-old'))
  t.not(result.primary.identities.includes('web-jane-old'))

  await registry.close()
})

test('mergeAccounts - rejects self-merge', async (t) => {
  const registry = await createTestRegistry()

  const user = await registry.createUser({ name: 'Karen' })

  try {
    await registry.mergeAccounts(user.userId, user.userId)
    t.fail('should have thrown')
  } catch (err) {
    t.ok(err.message.includes('Cannot merge'))
  }

  await registry.close()
})

// ========================================
// listAllUsers / listAllIdentities
// ========================================

test('listAllUsers - lists all users', async (t) => {
  const registry = await createTestRegistry()

  await registry.createUser({ name: 'User1' })
  await registry.createUser({ name: 'User2' })
  await registry.createUser({ name: 'User3' })

  const users = await registry.listAllUsers()
  t.is(users.length, 3)
  const names = users.map(u => u.name).sort()
  t.alike(names, ['User1', 'User2', 'User3'])

  await registry.close()
})

test('listAllIdentities - lists all identities', async (t) => {
  const registry = await createTestRegistry()

  await registry.createIdentity({ identityId: 'id1', method: 'web-identity', credentials: { publicKey: 'pk1' } })
  await registry.createIdentity({ identityId: 'id2', method: 'ssh-key', credentials: { fingerprint: 'fp2' } })
  await registry.createIdentity({ identityId: 'id3', method: 'noise-key', credentials: { publicKey: 'nk3' } })
  await registry.createIdentity({ identityId: 'id4', method: 'keet-identity', credentials: { publicKey: 'kk4' } })

  const identities = await registry.listAllIdentities()
  t.is(identities.length, 4)

  const methods = identities.map(i => i.method).sort()
  t.alike(methods, ['keet-identity', 'noise-key', 'ssh-key', 'web-identity'])

  await registry.close()
})

// ========================================
// Profile updates
// ========================================

test('updateUserProfile - updates name and picture', async (t) => {
  const registry = await createTestRegistry()

  const user = await registry.createUser({ name: 'Leo' })
  await registry.updateUserProfile(user.userId, {
    name: 'Leonardo',
    profilePicture: 'data:image/png;base64,abc'
  })

  const updated = await registry.getUser(user.userId)
  t.is(updated.name, 'Leonardo')
  t.is(updated.profilePicture, 'data:image/png;base64,abc')

  await registry.close()
})

// ========================================
// getMergePreview
// ========================================

test('getMergePreview - shows what would be transferred', async (t) => {
  const registry = await createTestRegistry()

  const primary = await registry.createUser({ name: 'Primary' })
  const secondary = await registry.createUser({ name: 'Secondary' })

  await registry.createIdentity({ identityId: 'web-sec', method: 'web-identity', credentials: { publicKey: 'pk_sec' } })
  await registry.createIdentity({ identityId: 'ssh-sec', method: 'ssh-key', credentials: { fingerprint: 'fp_sec' } })
  await registry.linkIdentity('web-sec', secondary.userId)
  await registry.linkIdentity('ssh-sec', secondary.userId)

  const preview = await registry.getMergePreview(primary.userId, secondary.userId)

  t.is(preview.primary.name, 'Primary')
  t.is(preview.secondary.name, 'Secondary')
  t.is(preview.secondary.identityCount, 2)
  t.is(preview.willTransfer.identities, 2)

  await registry.close()
})

// ========================================
// SSH fingerprint calculation
// ========================================

test('calculateSSHFingerprint - deterministic hash', async (t) => {
  const registry = await createTestRegistry()

  const fp1 = registry.calculateSSHFingerprint('ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... user@host')
  const fp2 = registry.calculateSSHFingerprint('ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... user@host')

  t.is(fp1, fp2) // Same input = same fingerprint
  t.is(fp1.length, 32) // 32 hex chars

  await registry.close()
})

test('calculateSSHFingerprint - different keys produce different fingerprints', async (t) => {
  const registry = await createTestRegistry()

  const fp1 = registry.calculateSSHFingerprint('ssh-ed25519 AAAA... user1@host')
  const fp2 = registry.calculateSSHFingerprint('ssh-rsa BBBB... user2@host')

  t.not(fp1, fp2)

  await registry.close()
})

// ========================================
// Autobase-specific properties
// ========================================

test('bootstrapKey and discoveryKey are available after ready', async (t) => {
  const registry = await createTestRegistry()

  t.ok(registry.bootstrapKey, 'should have bootstrapKey')
  t.ok(registry.bootstrapKeyHex, 'should have bootstrapKeyHex')
  t.is(typeof registry.bootstrapKeyHex, 'string')
  t.ok(registry.discoveryKey, 'should have discoveryKey')
  t.ok(registry.view, 'should have view')
  t.ok(registry.db, 'db should alias view')

  await registry.close()
})