OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / test / unit / identity-auth.test.js

346 lines · javascript ref:
import test from 'brittle'
import { IdentityRegistry } from '../../lib/auth/identity-registry.js'
import { ChallengeManager } from '../../lib/auth/challenge-manager.js'
import { WebIdentityProvider } from '../../lib/auth/providers/web-identity.js'
import { MockRegistry } from '../helpers/fixtures.js'

// ==================== USERNAME VALIDATION ====================

test('IdentityRegistry - validateUsername accepts valid usernames', (t) => {
  const db = new MockRegistry()
  const reg = new IdentityRegistry(db)

  t.ok(reg.validateUsername('alice').valid, 'simple name')
  t.ok(reg.validateUsername('bob-smith').valid, 'with dash')
  t.ok(reg.validateUsername('user123').valid, 'with numbers')
  t.ok(reg.validateUsername('a-b').valid, 'minimum 3 chars')
  t.ok(reg.validateUsername('a'.repeat(32)).valid, 'max 32 chars')
})

test('IdentityRegistry - validateUsername rejects invalid usernames', (t) => {
  const db = new MockRegistry()
  const reg = new IdentityRegistry(db)

  t.absent(reg.validateUsername('ab').valid, 'too short')
  t.absent(reg.validateUsername('a'.repeat(33)).valid, 'too long')
  t.absent(reg.validateUsername('Alice').valid, 'uppercase')
  t.absent(reg.validateUsername('-alice').valid, 'leading dash')
  t.absent(reg.validateUsername('alice-').valid, 'trailing dash')
  t.absent(reg.validateUsername('al--ce').valid, 'consecutive dashes')
  t.absent(reg.validateUsername('al ice').valid, 'spaces')
  t.absent(reg.validateUsername('al.ice').valid, 'dots')
  t.absent(reg.validateUsername('').valid, 'empty')
  t.absent(reg.validateUsername(null).valid, 'null')
})

test('IdentityRegistry - validateUsername rejects reserved names', (t) => {
  const db = new MockRegistry()
  const reg = new IdentityRegistry(db)

  t.absent(reg.validateUsername('api').valid, 'api reserved')
  t.absent(reg.validateUsername('admin').valid, 'admin reserved')
  t.absent(reg.validateUsername('web').valid, 'web reserved')
  t.absent(reg.validateUsername('auth').valid, 'auth reserved')
  t.absent(reg.validateUsername('sync').valid, 'sync reserved')
})

// ==================== SET USERNAME ====================

test('IdentityRegistry - setUsername succeeds with valid name', async (t) => {
  const db = new MockRegistry()
  const reg = new IdentityRegistry(db)

  const user = await reg.createUser({ name: 'Alice' })
  const updated = await reg.setUsername(user.userId, 'alice')

  t.is(updated.username, 'alice', 'username set')

  // Verify reverse lookup
  const found = await reg.getUserByUsername('alice')
  t.ok(found, 'found by username')
  t.is(found.userId, user.userId, 'correct user')
})

test('IdentityRegistry - setUsername rejects duplicate', async (t) => {
  const db = new MockRegistry()
  const reg = new IdentityRegistry(db)

  const user1 = await reg.createUser({ name: 'Alice' })
  const user2 = await reg.createUser({ name: 'Bob' })

  await reg.setUsername(user1.userId, 'alice')

  try {
    await reg.setUsername(user2.userId, 'alice')
    t.fail('should have thrown')
  } catch (err) {
    t.is(err.code, 'CONFLICT', 'conflict error')
    t.is(err.status, 409, '409 status')
  }
})

test('IdentityRegistry - setUsername allows same user to keep name', async (t) => {
  const db = new MockRegistry()
  const reg = new IdentityRegistry(db)

  const user = await reg.createUser({ name: 'Alice' })
  await reg.setUsername(user.userId, 'alice')

  // Same user setting same name should succeed
  const updated = await reg.setUsername(user.userId, 'alice')
  t.is(updated.username, 'alice')
})

test('IdentityRegistry - setUsername cleans up old name on change', async (t) => {
  const db = new MockRegistry()
  const reg = new IdentityRegistry(db)

  const user = await reg.createUser({ name: 'Alice' })
  await reg.setUsername(user.userId, 'alice')
  await reg.setUsername(user.userId, 'alice2')

  // Old name should be available
  const available = await reg.isUsernameAvailable('alice')
  t.ok(available, 'old name freed')

  // New name should resolve
  const found = await reg.getUserByUsername('alice2')
  t.ok(found, 'new name resolves')
  t.is(found.userId, user.userId)
})

test('IdentityRegistry - setUsername rejects invalid format', async (t) => {
  const db = new MockRegistry()
  const reg = new IdentityRegistry(db)

  const user = await reg.createUser({ name: 'Alice' })

  try {
    await reg.setUsername(user.userId, 'AB')
    t.fail('should have thrown')
  } catch (err) {
    t.is(err.code, 'VALIDATION_ERROR', 'validation error')
    t.is(err.status, 400, '400 status')
  }
})

// ==================== USERNAME AVAILABILITY ====================

test('IdentityRegistry - isUsernameAvailable', async (t) => {
  const db = new MockRegistry()
  const reg = new IdentityRegistry(db)

  t.ok(await reg.isUsernameAvailable('alice'), 'available before claim')

  const user = await reg.createUser({ name: 'Alice' })
  await reg.setUsername(user.userId, 'alice')

  t.absent(await reg.isUsernameAvailable('alice'), 'not available after claim')
  t.ok(await reg.isUsernameAvailable('bob'), 'other name still available')
})

test('IdentityRegistry - isUsernameAvailable rejects invalid format', async (t) => {
  const db = new MockRegistry()
  const reg = new IdentityRegistry(db)

  t.absent(await reg.isUsernameAvailable('AB'), 'invalid format returns false')
  t.absent(await reg.isUsernameAvailable('api'), 'reserved returns false')
})

// ==================== getUserByUsername ====================

test('IdentityRegistry - getUserByUsername returns null for unknown', async (t) => {
  const db = new MockRegistry()
  const reg = new IdentityRegistry(db)

  const result = await reg.getUserByUsername('nonexistent')
  t.is(result, null, 'returns null')
})

// ==================== createUser with username field ====================

test('IdentityRegistry - createUser includes username field', async (t) => {
  const db = new MockRegistry()
  const reg = new IdentityRegistry(db)

  const user = await reg.createUser()
  t.is(user.username, null, 'default username is null')

  const user2 = await reg.createUser({ username: 'bob' })
  t.is(user2.username, 'bob', 'username set in creation')
})

// ==================== CHALLENGE MANAGER ====================

test('ChallengeManager - generate and verify', async (t) => {
  const db = new MockRegistry()
  const cm = new ChallengeManager({ db })

  const challenge = await cm.generate()
  t.ok(challenge.challengeId, 'has challengeId')
  t.ok(challenge.challenge, 'has challenge')
  t.ok(challenge.expiresAt > Date.now(), 'expires in future')

  // Get should return the challenge
  const retrieved = await cm.get(challenge.challengeId)
  t.ok(retrieved, 'challenge retrieved')
  t.is(retrieved.challenge, challenge.challenge)

  // Verify should succeed
  const valid = await cm.verify(challenge.challengeId, challenge.challenge)
  t.ok(valid, 'verification succeeds')

  // Challenge is now consumed, verify again should fail
  const invalid = await cm.verify(challenge.challengeId, challenge.challenge)
  t.absent(invalid, 'replay prevented')

  cm.destroy()
})

test('ChallengeManager - expired challenge returns null', async (t) => {
  const db = new MockRegistry()
  const cm = new ChallengeManager({ db, ttl: 1 }) // 1ms TTL

  const challenge = await cm.generate()

  // Wait for expiry
  await new Promise(r => setTimeout(r, 10))

  const retrieved = await cm.get(challenge.challengeId)
  t.is(retrieved, null, 'expired challenge returns null')

  cm.destroy()
})

// ==================== WEB IDENTITY PROVIDER ====================

test('WebIdentityProvider - extractCredentials from headers', async (t) => {
  const db = new MockRegistry()
  const reg = new IdentityRegistry(db)
  const cm = new ChallengeManager({ db })
  const provider = new WebIdentityProvider(reg, cm)

  const pubKey = 'A'.repeat(44)
  const sig = 'B'.repeat(88)

  // Full credentials
  const creds = await provider.extractCredentials({
    headers: {
      'x-wirecore-publickey': pubKey,
      'x-wirecore-signature': sig,
      'x-wirecore-challenge': 'challenge-id-123'
    },
    url: '/api/test'
  })

  t.ok(creds, 'credentials extracted')
  t.is(creds.publicKey, pubKey)
  t.is(creds.signature, sig)
  t.is(creds.challengeId, 'challenge-id-123')

  // No credentials
  const none = await provider.extractCredentials({
    headers: {},
    url: '/api/test'
  })
  t.is(none, null, 'null when no creds')

  cm.destroy()
})

test('WebIdentityProvider - authenticate with signed challenge', async (t) => {
  const db = new MockRegistry()
  const reg = new IdentityRegistry(db)
  const cm = new ChallengeManager({ db })
  const provider = new WebIdentityProvider(reg, cm)

  // Import ANProto for signing
  const { ANProto } = await import('../../lib/anproto.js')

  // Generate keypair
  const kp = ANProto.gen()
  const publicKeyB64 = (await import('b4a')).default.toString(kp.publicKey, 'base64')

  // Generate challenge
  const challenge = await cm.generate()

  // Sign challenge
  const signature = ANProto.sign(challenge.challenge, kp)

  // Authenticate
  const result = await provider.authenticate({
    publicKey: publicKeyB64,
    signature,
    challengeId: challenge.challengeId
  })

  t.ok(result, 'authenticated')
  t.is(result.method, 'web-identity')
  t.ok(result.verified, 'verified')
  t.is(result.identityId, publicKeyB64, 'identity created with pubkey as ID')

  // User was auto-registered
  const user = await reg.getUserByIdentity(publicKeyB64)
  t.ok(user, 'user auto-registered')
  t.ok(user.identities.includes(publicKeyB64), 'identity linked to user')

  cm.destroy()
})

test('WebIdentityProvider - rejects invalid signature', async (t) => {
  const db = new MockRegistry()
  const reg = new IdentityRegistry(db)
  const cm = new ChallengeManager({ db })
  const provider = new WebIdentityProvider(reg, cm)

  const { ANProto } = await import('../../lib/anproto.js')
  const kp = ANProto.gen()
  const publicKeyB64 = (await import('b4a')).default.toString(kp.publicKey, 'base64')

  const challenge = await cm.generate()

  // Wrong signature
  const badSig = 'A'.repeat(88)

  try {
    await provider.authenticate({
      publicKey: publicKeyB64,
      signature: badSig,
      challengeId: challenge.challengeId
    })
    t.fail('should have thrown')
  } catch (err) {
    t.ok(err.message.includes('Invalid signature'), 'invalid signature error')
  }

  cm.destroy()
})

test('WebIdentityProvider - rejects expired challenge', async (t) => {
  const db = new MockRegistry()
  const reg = new IdentityRegistry(db)
  const cm = new ChallengeManager({ db, ttl: 1 }) // 1ms TTL
  const provider = new WebIdentityProvider(reg, cm)

  const challenge = await cm.generate()
  await new Promise(r => setTimeout(r, 10))

  const { ANProto } = await import('../../lib/anproto.js')
  const kp = ANProto.gen()
  const publicKeyB64 = (await import('b4a')).default.toString(kp.publicKey, 'base64')
  const signature = ANProto.sign(challenge.challenge, kp)

  try {
    await provider.authenticate({
      publicKey: publicKeyB64,
      signature,
      challengeId: challenge.challengeId
    })
    t.fail('should have thrown')
  } catch (err) {
    t.ok(err.message.includes('Invalid or expired'), 'expired challenge error')
  }

  cm.destroy()
})