OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity / test / identity-registry.test.js

549 lines · javascript ref:
import test from 'brittle'
import { createTestDb } from './helpers.js'
import { IdentityRegistry } from '../lib/auth/identity-registry.js'

async function createRegistry () {
  const { store, db, cleanup } = await createTestDb()
  const registry = new IdentityRegistry(store, { db })
  await registry.ready()
  return { registry, store, db, cleanup: async () => { await registry.close(); await cleanup() } }
}

test('createUser and getUser', async (t) => {
  const { registry, cleanup } = await createRegistry()

  // First user gets admin promotion (deterministic in apply)
  const user = await registry.createUser({ name: 'Alice' })
  t.ok(user.userId, 'has userId')
  t.is(user.name, 'Alice')
  t.ok(user.globalPermissions.includes('admin'), 'first user promoted to admin')

  const fetched = await registry.getUser(user.userId)
  t.is(fetched.name, 'Alice')
  t.is(fetched.userId, user.userId)

  await cleanup()
})

test('registerIdentity and getIdentityByPublicKey', async (t) => {
  const { registry, cleanup } = await createRegistry()

  await registry.registerIdentity({
    identityId: 'test-id',
    method: 'web-identity',
    publicKey: 'pubkey-abc123'
  })

  const identity = await registry.getIdentityByPublicKey('pubkey-abc123')
  t.ok(identity, 'found by pubkey')
  t.is(identity.identityId, 'test-id')
  t.is(identity.method, 'web-identity')

  await cleanup()
})

test('linkIdentityToUser and getUserByIdentity', async (t) => {
  const { registry, cleanup } = await createRegistry()

  const user = await registry.createUser({ name: 'Bob' })
  await registry.registerIdentity({
    identityId: 'bob-key',
    method: 'web-identity',
    publicKey: 'bob-pubkey'
  })

  await registry.linkIdentityToUser('bob-key', user.userId, { verified: true })

  const found = await registry.getUserByIdentity('bob-key')
  t.ok(found, 'found user by identity')
  t.is(found.userId, user.userId)

  // Direct-write db also updated
  const identity = await registry.getIdentity('bob-key')
  t.is(identity.userId, user.userId, 'identity record has userId')

  await cleanup()
})

test('autoRegister — first user gets admin', async (t) => {
  const { registry, cleanup } = await createRegistry()

  const result = await registry.autoRegister({
    identityId: 'first-key',
    method: 'web-identity',
    credentials: { publicKey: 'first-pubkey' }
  })

  t.ok(result.user, 'has user')
  t.ok(result.identity, 'has identity')
  t.ok(result.user.globalPermissions.includes('admin'), 'first user is admin')

  await cleanup()
})

test('autoRegister — second user gets read-only', async (t) => {
  const { registry, cleanup } = await createRegistry()

  await registry.autoRegister({
    identityId: 'first-key',
    method: 'web-identity',
    credentials: { publicKey: 'first-pubkey' }
  })

  const second = await registry.autoRegister({
    identityId: 'second-key',
    method: 'web-identity',
    credentials: { publicKey: 'second-pubkey' }
  })

  t.absent(second.user.globalPermissions.includes('admin'), 'second user is not admin')
  t.ok(second.user.globalPermissions.includes('read'), 'second user has read')

  await cleanup()
})

test('validateUsername', async (t) => {
  const { registry, cleanup } = await createRegistry()

  t.ok(registry.validateUsername('alice').valid, 'alice is valid')
  t.ok(registry.validateUsername('alice-bob').valid, 'alice-bob is valid')
  t.absent(registry.validateUsername('AB').valid, 'uppercase rejected')
  t.absent(registry.validateUsername('a').valid, 'too short rejected')
  t.absent(registry.validateUsername('-abc').valid, 'leading dash rejected')
  t.absent(registry.validateUsername('abc-').valid, 'trailing dash rejected')
  t.absent(registry.validateUsername('a--b').valid, 'consecutive dashes rejected')
  t.absent(registry.validateUsername('admin').valid, 'reserved name rejected')
  t.absent(registry.validateUsername('api').valid, 'reserved name rejected')

  await cleanup()
})

test('setUsername and getUserByUsername', async (t) => {
  const { registry, cleanup } = await createRegistry()

  const user = await registry.createUser({ name: 'Charlie' })
  await registry.setUsername(user.userId, 'charlie')

  const found = await registry.getUserByUsername('charlie')
  t.ok(found, 'found by username')
  t.is(found.userId, user.userId)
  t.is(found.username, 'charlie')

  await cleanup()
})

test('setUsername enforces uniqueness', async (t) => {
  const { registry, cleanup } = await createRegistry()

  const user1 = await registry.createUser({ name: 'A' })
  const user2 = await registry.createUser({ name: 'B' })

  await registry.setUsername(user1.userId, 'taken')

  try {
    await registry.setUsername(user2.userId, 'taken')
    t.fail('should have thrown')
  } catch (err) {
    t.is(err.code, 'CONFLICT', 'throws CONFLICT')
  }

  await cleanup()
})

test('isUsernameAvailable', async (t) => {
  const { registry, cleanup } = await createRegistry()

  t.ok(await registry.isUsernameAvailable('fresh'), 'unused name is available')

  const user = await registry.createUser({ name: 'Test' })
  await registry.setUsername(user.userId, 'fresh')

  t.absent(await registry.isUsernameAvailable('fresh'), 'taken name is not available')
  t.absent(await registry.isUsernameAvailable('admin'), 'reserved name is not available')

  await cleanup()
})

test('updateUserProfile', async (t) => {
  const { registry, cleanup } = await createRegistry()

  const user = await registry.createUser({ name: 'Old Name' })
  const updated = await registry.updateUserProfile(user.userId, { name: 'New Name', theme: 'dark' })

  t.is(updated.name, 'New Name')
  t.is(updated.theme, 'dark')

  await cleanup()
})

test('upgradePermissions', async (t) => {
  const { registry, cleanup } = await createRegistry()

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

  const fetched = await registry.getUser(user.userId)
  t.ok(fetched.globalPermissions.includes('admin'), 'has admin after upgrade')

  await cleanup()
})

test('revokeIdentity and isRevoked', async (t) => {
  const { registry, cleanup } = await createRegistry()

  await registry.registerIdentity({
    identityId: 'revoke-me',
    method: 'web-identity',
    publicKey: 'revoke-pubkey'
  })

  t.absent(await registry.isRevoked('revoke-me'), 'not revoked initially')

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

  t.ok(await registry.isRevoked('revoke-me'), 'revoked after revokeIdentity')

  await cleanup()
})

test('restoreIdentity', async (t) => {
  const { registry, cleanup } = await createRegistry()

  await registry.registerIdentity({
    identityId: 'restore-me',
    method: 'web-identity',
    publicKey: 'restore-pubkey'
  })

  await registry.revokeIdentity('restore-me')
  t.ok(await registry.isRevoked('restore-me'), 'revoked')

  await registry.restoreIdentity('restore-me')
  t.absent(await registry.isRevoked('restore-me'), 'restored')

  await cleanup()
})

test('mergeAccounts', async (t) => {
  const { registry, cleanup } = await createRegistry()

  const primary = await registry.createUser({ name: 'Primary', globalPermissions: ['read'] })
  const secondary = await registry.createUser({ name: 'Secondary', globalPermissions: ['read', 'write'] })

  await registry.registerIdentity({ identityId: 'sec-id', method: 'web-identity', publicKey: 'sec-pub' })
  await registry.linkIdentityToUser('sec-id', secondary.userId, { verified: true })

  await registry.mergeAccounts(primary.userId, secondary.userId)

  const merged = await registry.getUser(secondary.userId)
  t.is(merged.mergedInto, primary.userId, 'secondary marked as merged')

  const kept = await registry.getUser(primary.userId)
  t.ok(kept.globalPermissions.includes('write'), 'permissions merged')

  await cleanup()
})

test('listUsers excludes merged accounts', async (t) => {
  const { registry, cleanup } = await createRegistry()

  const u1 = await registry.createUser({ name: 'Keep' })
  const u2 = await registry.createUser({ name: 'Merge' })

  await registry.mergeAccounts(u1.userId, u2.userId)

  const users = await registry.listUsers()
  t.is(users.length, 1, 'only active user listed')
  t.is(users[0].name, 'Keep')

  await cleanup()
})

test('deleteUser', async (t) => {
  const { registry, cleanup } = await createRegistry()

  const user = await registry.createUser({ name: 'Delete Me' })
  t.ok(await registry.getUser(user.userId), 'exists before delete')

  await registry.deleteUser(user.userId)
  t.absent(await registry.getUser(user.userId), 'gone after delete')

  await cleanup()
})

// --- New methods ---

test('getUserWithIdentities', async (t) => {
  const { registry, cleanup } = await createRegistry()

  const result = await registry.autoRegister({
    identityId: 'key-a',
    method: 'web-identity',
    credentials: { publicKey: 'key-a' }
  })

  const full = await registry.getUserWithIdentities(result.user.userId)
  t.ok(full, 'returns result')
  t.is(full.user.userId, result.user.userId)
  t.ok(full.identities.length >= 1, 'has identities')
  t.is(full.identities[0].identityId, 'key-a')

  await cleanup()
})

test('listPublicUsers', async (t) => {
  const { registry, cleanup } = await createRegistry()

  const u1 = await registry.createUser({ name: 'Alice' })
  const u2 = await registry.createUser({ name: 'Bob' })

  await registry.setUsername(u1.userId, 'alice')
  // u2 has no username

  const publicUsers = await registry.listPublicUsers()
  t.is(publicUsers.length, 1, 'only users with username')
  t.is(publicUsers[0].username, 'alice')

  await cleanup()
})

test('deleteUserCascade', async (t) => {
  const { registry, cleanup } = await createRegistry()

  const result = await registry.autoRegister({
    identityId: 'del-key',
    method: 'web-identity',
    credentials: { publicKey: 'del-key' }
  })
  await registry.setUsername(result.user.userId, 'deleteme')

  // Verify everything exists
  t.ok(await registry.getUser(result.user.userId), 'user exists')
  t.ok(await registry.getIdentityByPublicKey('del-key'), 'pubkey index exists')
  t.ok(await registry.getUserByUsername('deleteme'), 'username index exists')

  await registry.deleteUserCascade(result.user.userId)

  t.absent(await registry.getUser(result.user.userId), 'user gone')
  t.absent(await registry.getIdentityByPublicKey('del-key'), 'pubkey index gone')

  await cleanup()
})

test('importIdentity links new key to existing user', async (t) => {
  const { registry, cleanup } = await createRegistry()

  const result = await registry.autoRegister({
    identityId: 'device1',
    method: 'web-identity',
    credentials: { publicKey: 'device1' }
  })

  // Import a second keypair for the same user
  const imported = await registry.importIdentity(result.user.userId, 'device2')
  t.is(imported.publicKey, 'device2')
  t.is(imported.userId, result.user.userId)

  // Both keys resolve to the same user
  const user1 = await registry.getUserByIdentity('device1')
  const user2 = await registry.getUserByIdentity('device2')
  t.is(user1.userId, user2.userId, 'both devices resolve to same user')

  await cleanup()
})

test('importIdentity rejects key already linked to different user', async (t) => {
  const { registry, cleanup } = await createRegistry()

  const u1 = await registry.autoRegister({
    identityId: 'user1key',
    method: 'web-identity',
    credentials: { publicKey: 'user1key' }
  })

  await registry.autoRegister({
    identityId: 'user2key',
    method: 'web-identity',
    credentials: { publicKey: 'user2key' }
  })

  try {
    await registry.importIdentity(u1.user.userId, 'user2key')
    t.fail('should have thrown')
  } catch (err) {
    t.ok(err.message.includes('already linked'), 'throws on conflict')
  }

  await cleanup()
})

test('getIdentityBySSHKey — reverse lookup by fingerprint', async (t) => {
  const { registry, cleanup } = await createRegistry()

  await registry.registerIdentity({
    identityId: 'ssh-id-1',
    method: 'ssh-key',
    credentials: { fingerprint: 'SHA256:abc123def456', type: 'ssh-ed25519' }
  })

  const found = await registry.getIdentityBySSHKey('SHA256:abc123def456')
  t.ok(found, 'found by SSH fingerprint')
  t.is(found.identityId, 'ssh-id-1')
  t.is(found.method, 'ssh-key')

  const notFound = await registry.getIdentityBySSHKey('SHA256:nonexistent')
  t.is(notFound, null, 'returns null for unknown fingerprint')

  await cleanup()
})

test('getIdentityByNoiseKey — reverse lookup by noise public key', async (t) => {
  const { registry, cleanup } = await createRegistry()

  await registry.registerIdentity({
    identityId: 'noise-id-1',
    method: 'noise-key',
    credentials: { publicKey: 'deadbeef'.repeat(8) }
  })

  const found = await registry.getIdentityByNoiseKey('deadbeef'.repeat(8))
  t.ok(found, 'found by Noise key')
  t.is(found.identityId, 'noise-id-1')
  t.is(found.method, 'noise-key')

  const notFound = await registry.getIdentityByNoiseKey('00'.repeat(32))
  t.is(notFound, null, 'returns null for unknown noise key')

  await cleanup()
})

test('registerIdentity indexes all key types correctly', async (t) => {
  const { registry, cleanup } = await createRegistry()

  await registry.registerIdentity({
    identityId: 'web-1', method: 'web-identity', publicKey: 'web-pubkey-123'
  })
  await registry.registerIdentity({
    identityId: 'ssh-1', method: 'ssh-key',
    credentials: { fingerprint: 'SHA256:sshfp123' }
  })
  await registry.registerIdentity({
    identityId: 'noise-1', method: 'noise-key',
    credentials: { publicKey: 'noisepk456' }
  })

  t.ok(await registry.getIdentityByPublicKey('web-pubkey-123'), 'web identity found by pubkey')
  t.ok(await registry.getIdentityBySSHKey('SHA256:sshfp123'), 'SSH identity found by fingerprint')
  t.ok(await registry.getIdentityByNoiseKey('noisepk456'), 'Noise identity found by noise key')

  t.is(await registry.getIdentityBySSHKey('web-pubkey-123'), null, 'web key not found via SSH lookup')
  t.is(await registry.getIdentityByNoiseKey('SHA256:sshfp123'), null, 'SSH fp not found via Noise lookup')

  await cleanup()
})

// ── Deactivation ─────────────────────────────────────────────

test('deactivateUser blocks access, reactivateUser restores', async (t) => {
  const { registry, cleanup } = await createRegistry()

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

  await registry.deactivateUser(user.userId, { reason: 'spam' })

  const deactivated = await registry.getUser(user.userId)
  t.ok(deactivated.deactivatedAt, 'has deactivatedAt timestamp')
  t.is(deactivated.deactivatedReason, 'spam', 'has reason')
  t.ok(await registry.isDeactivated(user.userId), 'isDeactivated returns true')

  await registry.reactivateUser(user.userId)

  const reactivated = await registry.getUser(user.userId)
  t.absent(reactivated.deactivatedAt, 'deactivatedAt cleared')
  t.absent(reactivated.deactivatedReason, 'reason cleared')
  t.absent(await registry.isDeactivated(user.userId), 'isDeactivated returns false')

  await cleanup()
})

test('deactivateUser — cannot deactivate last admin', async (t) => {
  const { registry, cleanup } = await createRegistry()

  const admin = await registry.createUser({ name: 'Solo Admin' })
  // First user is admin. Try to deactivate — should be silently skipped.
  await registry.deactivateUser(admin.userId)

  const stillActive = await registry.getUser(admin.userId)
  t.absent(stillActive.deactivatedAt, 'last admin cannot be deactivated')

  await cleanup()
})

test('hasAnyAdmin', async (t) => {
  const { registry, cleanup } = await createRegistry()

  t.absent(await registry.hasAnyAdmin(), 'no admin before any users')

  await registry.createUser({ name: 'First' })
  t.ok(await registry.hasAnyAdmin(), 'first user is admin')

  await cleanup()
})

test('listAllIdentities', async (t) => {
  const { registry, cleanup } = await createRegistry()

  await registry.registerIdentity({ identityId: 'id-1', method: 'web-identity', publicKey: 'pk1' })
  await registry.registerIdentity({ identityId: 'id-2', method: 'ssh-key', credentials: { fingerprint: 'fp2' } })

  const all = await registry.listAllIdentities()
  t.is(all.length, 2, 'lists all identities')
  t.ok(all.some(i => i.identityId === 'id-1'))
  t.ok(all.some(i => i.identityId === 'id-2'))

  await cleanup()
})

// --- Timestamp preservation (identity-publish-reconciler prerequisite) ---
// The reconciler re-emits records it already holds. createUser must NOT bump
// the record's clock when explicit timestamps are passed, or a re-announce
// would out-clock a peer's newer edit and break keyed LWW convergence.

test('createUser preserves explicit createdAt/updatedAt', async (t) => {
  const { registry, cleanup } = await createRegistry()

  // Bootstrap a first user so the replayed one isn't the first-admin (and to
  // mirror the real reconcile path where the registry is already populated).
  await registry.createUser({ name: 'Admin' })

  const createdAt = 1700000000000
  const updatedAt = 1700000500000
  const user = await registry.createUser({
    userId: 'replayed-uid-1',
    name: 'Replayed',
    createdAt,
    updatedAt
  })
  t.is(user.createdAt, createdAt, 'createdAt preserved on return')
  t.is(user.updatedAt, updatedAt, 'updatedAt preserved on return')

  const fetched = await registry.getUser('replayed-uid-1')
  t.is(fetched.createdAt, createdAt, 'createdAt persisted in view')
  t.is(fetched.updatedAt, updatedAt, 'updatedAt persisted in view')

  await cleanup()
})

test('createUser defaults timestamps to now when not provided', async (t) => {
  const { registry, cleanup } = await createRegistry()

  const before = Date.now()
  const user = await registry.createUser({ name: 'NowUser' })
  t.ok(user.createdAt >= before, 'createdAt defaulted to ~now')
  t.ok(user.updatedAt >= before, 'updatedAt defaulted to ~now')
  t.ok(typeof user.updatedAt === 'number', 'updatedAt is set even without opts')

  await cleanup()
})