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