Skip to main content

Testing

WIP

An in-memory engine for tests, capturing what would be mailed, a redis double, and driving your routes

An engine for tests

import { createTest } from '@gentleduck/auth/test'
import { expect, it } from 'vitest'

it('signs in with the right password', async () => {
  const auth = createTest()
  const ada = await auth.identities.create({ profile: { username: 'ada', email: 'ada@example.com' } })
  await auth.passwords.set(ada.id, 'correct horse battery staple', auth.cfg.stores.credentials)

  const { sid, session } = await auth.flows.signIn({
    providerId: 'password',
    input: { email: 'ada@example.com', password: 'correct horse battery staple' },
  })

  expect(session?.identityId).toBe(ada.id)
  const resolved = await auth.resolveSession({ headers: new Headers({ authorization: `Bearer ${sid}` }) }).orNull()
  expect(resolved?.identity?.profile.email).toBe('ada@example.com')
})

createTest() is an engine over a MemoryAdapter, the bearer transport, a memory limiter allowing 1000 attempts a minute, and the password, MFA and API key providers. Each piece can be replaced:

import { MemoryLimiter } from '@gentleduck/auth/limiters/memory'

const auth = createTest({ limiter: new MemoryLimiter({ max: 2, windowMs: 60_000 }) })
// The third wrong password in a minute throws AUTH_RATE_LIMITED.
OverrideReplaces
adapter, storesThe memory adapter, or the whole store bag.
transport, limiter, eventsThose engine pieces.
providersAdded after the three built in.
passwords, mfa, apiKeysThose providers' options.

noopLimiter() from @gentleduck/auth/limiters/mock never refuses, for a test that runs the same flow many times.

Capturing what would be sent

import { MemoryAdapter } from '@gentleduck/auth/adapters/memory'
import { createAuth } from '@gentleduck/auth/core'
import { authTestDeliver } from '@gentleduck/auth/test'

const { deliver, outbox } = authTestDeliver()
const auth = createAuth({ baseUrl: 'https://app.example.com', stores: new MemoryAdapter(), deliver, strict: false })

const ada = await auth.identities.create({ profile: { username: 'ada', email: 'ada@example.com' } })
await auth.flows.requestEmailVerification({ identityId: ada.id })

expect(outbox[0]).toMatchObject({ kind: 'email-verification', identityId: ada.id })
const token = new URL(String(outbox[0]?.vars.url)).searchParams.get('token')

outbox holds every message as deliver would have received it, with identityId and tenantId added.

Redis

fakeRedis() from @gentleduck/auth/test is an in-memory client for the adapters/redis stores:

import { session } from '@gentleduck/auth/adapters/redis'
import { fakeRedis } from '@gentleduck/auth/test'

const sessions = session({ redis: fakeRedis() })

Your routes

A route over a Fetch-native framework takes a Request, so a test calls it with no server. With a Hono app:

const res = await app.request('/auth/signin', {
  body: JSON.stringify({ providerId: 'password', input: { email: 'a@x.com', password: 'correct-horse-battery' } }),
  headers: { 'content-type': 'application/json', 'sec-fetch-site': 'same-origin' },
  method: 'POST',
})
expect(res.headers.get('set-cookie')).toContain('duck-sid=')

Send sec-fetch-site: cross-site to see the CSRF guard answer 403 AUTH_CSRF.