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