/**
* 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()
})