authtest

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 14, 2026 License: Apache-2.0 Imports: 23 Imported by: 0

README

authtest

The test harness for an app that embeds the library. One call builds the library on a fresh database, with recorders where the plugins send mail and texts, a clock the test moves, and a signed-in session in every form a request can carry. It is part of the core module and imports no database driver: the test imports its own, and the harness opens the database by driver name.

import (
    "github.com/ubgo/auth/authtest"
    _ "modernc.org/sqlite"
)

func newEnv(t *testing.T) *authtest.Env {
    return authtest.New(t, authtest.WithPlugins(func(e *authtest.Env) []auth.Plugin {
        return []auth.Plugin{
            email.New(email.Login(), email.Recover(), email.WithMailer(e.Mail)),
            password.New(password.Argon2(password.Argon2Params{Memory: 64, Time: 1, Threads: 1})),
        }
    }))
}

The guide is Test your app; the recipe is examples/app-tests.

What you get

Piece What it does
authtest.New(t, opts...) Opens SQLite in memory (one shared-cache database per Env), wires the plugins and options over a frozen clock and a memory rate store, migrates, and closes everything in t.Cleanup. Any failure fails the test
authtest.WithPlugins(fn) The plugin list, as a function of the Env so the plugins take e.Mail and e.SMS. Required
authtest.WithOptions(opts...) More auth.Options, after the harness's own
authtest.WithDB(db, dialect) Run on an open Postgres or MySQL instead of SQLite
authtest.WithSQLite(driver, dsn) Another SQLite driver or a file
authtest.WithBaseURL(u) Replace authtest.BaseURL (https://app.example.test)
e.CreateUser(name, ids...) A user with verified, primary identities, through the real core API
e.NewUser(name, ids...) A UserFixture built in memory and not saved, as better-auth's ctx.test.createUser returns one: its final id from the Auth's generator, DefaultUserName when name is empty, the Env clock as CreatedAt, and the identities. Edit it, refer to its id, then save it. Nothing is checked until then
e.SaveUser(f) Writes a fixture through the real core API (CreateUser with its id, then each identity as verified and primary), so every hook and uniqueness rule runs; returns the stored auth.User
e.DeleteUser(id) Deletes the user through a.DeleteUser, its identities, sessions and plugin rows with it, as better-auth's deleteUser
e.SignIn(userID) A Session: Cookie(), Authorization(), Apply(req), ApplyBearer(req), Context(ctx), BearerContext(ctx); a user with a second factor gets a pending session, as a sign-in plugin would create
e.Anonymous(ctx) A signed-out browser request from the app's origin
e.Browser(handler) A TLS test server with a cookie jar sending the app's Origin: Post(path, body), Get(path), Do(req), SetSession(s), Cookie(name); redirects are not followed
Response Expect(status), Decode(&v), ErrorCode(), plus Status, Header, Body
e.Mail An email.Mailer that records: Sent(), To(addr), Last(addr), LastLink(addr), LastToken(addr), LastCode(addr), Reset(). Every reader waits for deliveries in flight
e.SMS A phone.SMSSender that records: To(number), LastCode(number), Reset()
e.Clock Now(), Advance(d), Set(t); starts at the real time, rounded to microseconds
authtest.WithFaults(f) Runs the Env's SQLite through a fault counter (authtest.Faults, from authtest.NewFaults()), kept as e.Faults; disarmed until f.Arm(n) makes the n-th database call (exec, query, prepare, begin, commit) fail with authtest.ErrFault, and f.Disarm() reports whether it was reached
authtest.SweepFaults(t, fresh, setup, swallowOK...) Runs an operation once per database call it makes, each on a fresh Env from fresh with a different call failing, and fails the test when a faulted run returns no error, or when no run finishes within authtest.MaxFaultSteps calls: the proof that a plugin surfaces every database failure. swallowOK names the steps where swallowing is documented. f.Connector(base) and authtest.DSNConnector(driver, dsn) wrap any other database/sql driver the same way

Rules it follows

  • A request body that carries a password is a map, not the library's request type: password.Secret (an auth.Token) encodes as "[redacted]" on purpose, so marshalling password.SignInRequest would send that text. A request type that carries a link or poll token (invite.TokenRequest, email.TokenRequest, emailchallenge.StartOrToken) uses auth.WireToken, which encodes as its value, so it can be posted as it is.
  • The browser is TLS because the session cookie is __Host- and Secure, and a cookie jar keeps such a cookie only for an https URL.
  • The clock starts at the real time: an HTTP client judges cookie expiries against the real clock and would drop every cookie from a clock in the past.
  • SignIn goes through auth.CreateSession, so a user who must pass a second factor gets a pending session, exactly as after a password sign-in. A test of the whole sign-in flow calls the plugin's operation through the browser instead.

Documentation

Overview

Package authtest is the test harness for applications that embed the library: an Auth on a fresh database per test, a clock the test moves, a mailbox and an SMS box that record what the plugins send, a signed-in session as a cookie, a bearer header or a Go context, and a browser with a cookie jar over the app's handler.

It exists because every app test otherwise writes the same eighty lines: open SQLite with the right pragmas, migrate, record mail, wait for deliveries, mark the Secure cookie for a plain test server, pull the token out of a link. Each of those was written in this repository's own examples several times before it was made public here.

The package is part of the core module and imports no database driver: the test imports its own (`_ "modernc.org/sqlite"`) and New opens the database by driver name, so the module's dependency rule holds.

Index

Constants

View Source
const BaseURL = "https://app.example.test"

BaseURL is the public URL every Env runs under unless WithBaseURL says otherwise: an https origin, so the __Host- cookie rules hold, on a name that resolves nowhere.

View Source
const DefaultUserName = "Test User"

DefaultUserName is the name NewUser gives a fixture that names none, as better-auth's test utils default theirs.

View Source
const MaxFaultSteps = 200

MaxFaultSteps bounds a SweepFaults run. No operation in the library makes this many database calls; reaching it means the sweep never saw the operation finish without a fault, which is a loop in the test, not a slow operation.

View Source
const SQLiteDriver = "sqlite"

SQLiteDriver is the database/sql driver name New opens by default: the one modernc.org/sqlite registers. The test must import that driver, or New fails with sql's "unknown driver".

Variables

View Source
var ErrFault = errors.New("authtest: injected database fault")

ErrFault is the error an injected database failure returns. A test compares against it to prove the failure reached the caller rather than being eaten.

Functions

func DSNConnector

func DSNConnector(d driver.Driver, dsn string) driver.Connector

DSNConnector is a driver.Connector that opens dsn with d, for a driver that offers only Open (modernc.org/sqlite, for one). The driver of an open *sql.DB is db.Driver(), so a test needs no import beyond the one that registers the driver.

func SweepFaults

func SweepFaults[E any](t SweepT, fresh func() (E, *Faults), setup func(e E) func(ctx context.Context) error, swallowOK ...int)

SweepFaults proves that an operation surfaces every database failure it can meet. It runs the operation once per database call it makes, each time on a fresh state from fresh with a different call failing, until a run finishes without reaching the armed call.

fresh builds a new database and returns it with its Faults (for an Env: New with WithFaults); setup prepares the state the operation needs, with faults off, and returns the operation. Every faulted run must return an error: a nil error after a fault means a failure was swallowed, and the operation reported success for work that did not happen. swallowOK names the steps where swallowing is the documented behaviour (a best-effort "last used" update, say), so an intentional one is written down at the test rather than silently allowed.

Types

type Browser

type Browser struct {

	// Origin is what the browser sends as the Origin header on a mutation;
	// change it to play a cross-site page.
	Origin string
	// contains filtered or unexported fields
}

Browser drives an http.Handler the way a browser on the app's page does: over TLS, because the session cookie is __Host- and Secure and a cookie jar keeps such a cookie only for an https URL; with a cookie jar, so a sign-in's cookie rides on the next request; and sending the app's Origin on every mutation, so the origin check passes. Redirects are not followed, so a test sees the 302 an OAuth start answers.

func (*Browser) Client

func (b *Browser) Client() *http.Client

Client returns the underlying client, with the jar, for a request the helpers do not shape.

func (*Browser) Cookie

func (b *Browser) Cookie(name string) *http.Cookie

Cookie returns the jar's cookie called name for the server, or nil.

func (*Browser) Do

func (b *Browser) Do(req *http.Request) *Response

Do sends req through the browser and reads the whole answer.

func (*Browser) Get

func (b *Browser) Get(path string) *Response

Get fetches path.

func (*Browser) Post

func (b *Browser) Post(path string, body any) *Response

Post sends body as JSON to path with the browser's Origin. body is encoded with encoding/json; pass a map for a request that carries a password, since password.Secret (an auth.Token) encodes as "[redacted]" on purpose. A request type whose token is an auth.WireToken (a link or poll token) encodes it, so it can be passed as it is. A nil body sends "{}".

func (*Browser) SetSession

func (b *Browser) SetSession(s *Session)

SetSession puts a session in the jar, as if the browser had signed in through the handler; the next request carries its cookie.

func (*Browser) URL

func (b *Browser) URL() string

URL returns the server's base URL, for a test that builds its own request.

type Clock

type Clock struct {
	// contains filtered or unexported fields
}

Clock is a settable clock for auth.WithClock. Instants are rounded to the microsecond because Postgres and MySQL store no finer, and a test that compares a stored time with Now would otherwise differ in the nanoseconds.

func NewClock

func NewClock() *Clock

NewClock returns a clock stopped at the current time, in UTC.

func (*Clock) Advance

func (c *Clock) Advance(d time.Duration)

Advance moves the clock forward by d.

func (*Clock) Now

func (c *Clock) Now() time.Time

Now returns the clock's time. It does not move by itself.

func (*Clock) Set

func (c *Clock) Set(t time.Time)

Set moves the clock to t, in UTC.

type Env

type Env struct {
	T testing.TB
	// Auth is wired, migrated and closed when the test ends.
	Auth *auth.Auth
	// DB and Dialect are what Auth runs on.
	DB      *sql.DB
	Dialect sqlb.Dialect
	// Clock is the clock Auth measures every expiry by; Advance it to expire
	// sessions, codes and links without sleeping.
	Clock *Clock
	// Mail records every mail the email plugin sends when the plugin is built
	// with email.WithMailer(env.Mail); SMS does the same for the phone plugin
	// with phone.WithSMS(env.SMS).
	Mail *Mailbox
	SMS  *SMSBox
	// BaseURL is the app's public URL as Auth knows it.
	BaseURL string
	// Faults fails a chosen database call when the Env was built with
	// WithFaults, and is nil otherwise.
	Faults *Faults
}

Env is one test's wired library and the fakes around it.

func New

func New(t testing.TB, opts ...Option) *Env

New builds the Env: it opens the database (SQLite in memory unless WithDB or WithSQLite), wires the plugins and options over a frozen clock and a memory rate store on that clock, migrates, and registers Close with the test's Cleanup. Any failure fails the test at once; a harness that returned errors would make every test start with four checks.

func (*Env) Anonymous

func (e *Env) Anonymous(ctx context.Context) context.Context

Anonymous returns ctx as a signed-out browser request from the app's own origin: what a public operation (sign-in, forgot password) sees.

func (*Env) Browser

func (e *Env) Browser(h http.Handler) *Browser

Browser serves h over TLS for the rest of the test and returns a Browser on it. One Env can have several, for two users signed in at once.

func (*Env) CreateUser

func (e *Env) CreateUser(name string, ids ...Identity) auth.User

CreateUser creates a user with the given verified, primary identities, through the real core API, so every plugin hook runs. It is the fixture most tests start with; a test of sign-up itself goes through the plugin's operation instead.

func (*Env) DeleteUser

func (e *Env) DeleteUser(id string)

DeleteUser deletes user id through the core (a.DeleteUser), so its identities, sessions and every plugin's rows go with it, as better-auth's deleteUser does. An unknown id fails the test.

func (*Env) NewUser

func (e *Env) NewUser(name string, ids ...Identity) UserFixture

NewUser builds a UserFixture without touching the database: an id from the Auth's generator (auth.WithIDs), DefaultUserName unless the test passes a name, the Env's clock as the creation time, and the given verified, primary identities. Nothing is checked until SaveUser, which is where every rule (uniqueness, hooks) runs.

func (*Env) SaveUser

func (e *Env) SaveUser(f UserFixture) auth.User

SaveUser writes f through the real core API (CreateUser, then each identity), so every plugin hook runs, and returns the stored user. A taken id or identity fails the test.

func (*Env) SignIn

func (e *Env) SignIn(userID string) *Session

SignIn creates a session for userID through the core, the way a sign-in plugin does after it checked a credential, so a user's second factor still applies: a user who must pass one gets a pending session. For a test that needs the whole sign-in flow, call the plugin's operation.

type Faults

type Faults struct {
	// contains filtered or unexported fields
}

Faults counts database calls and fails the one numbered at: armed, the at-th call of any kind (exec, query, prepare, begin, commit) returns ErrFault; disarmed, every call passes. It is the switch behind the connector Connector returns.

Why a driver and not a mock: the library talks to *sql.DB through sqlb, and the only honest way to reach a "the database failed here" branch is to make a real database fail at that exact call. Counting calls instead of matching SQL text keeps a test independent of the queries' wording.

func NewFaults

func NewFaults() *Faults

NewFaults returns a disarmed counter.

func (*Faults) Arm

func (f *Faults) Arm(at int)

Arm starts counting from zero and fails call number at.

func (*Faults) Connector

func (f *Faults) Connector(base driver.Connector) driver.Connector

Connector wraps base so every call on its connections passes through f first. Any database/sql driver works; DSNConnector adapts a driver that has no connector of its own. Open the result with sql.OpenDB.

func (*Faults) Disarm

func (f *Faults) Disarm() bool

Disarm stops counting and reports whether the armed call was reached.

type Identity

type Identity struct {
	Kind  auth.IdentifierKindName
	Value string
}

Identity is one identifier a test user is created with: a verified, primary identity of Kind holding Value, such as {email.KindEmail, "ada@example.test"}.

type Mailbox

type Mailbox struct {
	// contains filtered or unexported fields
}

Mailbox records mail instead of sending it. It is an email.Mailer; build the email plugin with email.WithMailer(env.Mail). Every reader waits for the deliveries in flight first, because a message goes out after the transaction commits, in the background, and a test that read the box right after the request would race it.

func (*Mailbox) Last

func (m *Mailbox) Last(addr string) (email.Mail, bool)

Last returns the newest mail sent to addr, and false when there is none.

func (*Mailbox) LastCode

func (m *Mailbox) LastCode(addr string) string

LastCode returns the one-time code in the newest mail to addr, for the email plugin's codes. It fails the test when no mail carries one.

func (m *Mailbox) LastLink(addr string) *url.URL

LastLink returns the first URL in the newest mail to addr that carries a "token" query parameter: the verify, reset, sign-in or confirmation link. It fails the test when there is none.

func (*Mailbox) LastToken

func (m *Mailbox) LastToken(addr string) auth.Token

LastToken returns the token of LastLink(addr).

func (*Mailbox) Reset

func (m *Mailbox) Reset()

Reset forgets every recorded mail, so a test can assert on what one step sends.

func (*Mailbox) Send

func (m *Mailbox) Send(_ context.Context, mail email.Mail) error

Send records m.

func (*Mailbox) Sent

func (m *Mailbox) Sent() []email.Mail

Sent returns every mail recorded so far, oldest first.

func (*Mailbox) To

func (m *Mailbox) To(addr string) []email.Mail

To returns the mail sent to addr, oldest first. addr is compared with the normalized address the plugin sends to, which is lower case.

type Option

type Option func(*settings)

Option configures New.

func WithBaseURL

func WithBaseURL(u string) Option

WithBaseURL replaces BaseURL.

func WithDB

func WithDB(db *sql.DB, d sqlb.Dialect) Option

WithDB runs the Env on an open database instead of SQLite in memory: a Postgres or MySQL the test suite reset itself. The Env does not close it.

func WithFaults

func WithFaults(f *Faults) Option

WithFaults runs the Env's SQLite through f (Faults.Connector): every database call the Env makes, migrations included, passes f's counter, which stays disarmed until the test arms it. Env.Faults is f. It applies to the SQLite New opens (WithSQLite included), not to WithDB.

func WithOptions

func WithOptions(opts ...auth.Option) Option

WithOptions adds options for auth.New after the harness's own (database, base URL, clock, rate store), so a test can add a session cache, a policy or a hook. An option that repeats one of the harness's replaces it.

func WithPlugins

func WithPlugins(fn func(e *Env) []auth.Plugin) Option

WithPlugins sets the plugin list. It is a function of the Env because the plugins take the recorders (env.Mail, env.SMS) and the Env exists first. New fails without it: a library with no sign-in method cannot start.

func WithSQLite

func WithSQLite(driver, dsn string) Option

WithSQLite opens SQLite through another driver or DSN, for a test that uses mattn/go-sqlite3 (whose pragma syntax differs) or wants a file in t.TempDir(). The dialect is SQLite's.

type Response

type Response struct {
	Method string
	Path   string
	Status int
	Header http.Header
	Body   []byte
	// contains filtered or unexported fields
}

Response is one answer, read in full.

func (*Response) Decode

func (r *Response) Decode(v any)

Decode unmarshals the JSON body into v, failing the test when it does not parse. v is any because the answer's type is the test's to choose; this is the boundary where encoded bytes meet typed Go.

func (*Response) ErrorCode

func (r *Response) ErrorCode() string

ErrorCode returns the error code of an error answer, or "" when the body is not one. Compare it with the sentinel's Code: auth.ErrSignedOut.Code.

func (*Response) Expect

func (r *Response) Expect(want int) *Response

Expect fails the test unless the status is want, printing the body, and returns the response for chaining: b.Post(...).Expect(200).Decode(&out).

type SMSBox

type SMSBox struct {
	// contains filtered or unexported fields
}

SMSBox records text messages instead of sending them. It is a phone.SMSSender; build the phone plugin with phone.WithSMS(env.SMS).

func (*SMSBox) LastCode

func (s *SMSBox) LastCode(number string) string

LastCode returns the one-time code in the newest text to number. It fails the test when there is none.

func (*SMSBox) Reset

func (s *SMSBox) Reset()

Reset forgets every recorded text.

func (*SMSBox) Send

func (s *SMSBox) Send(_ context.Context, number, text string) error

Send records text for number.

func (*SMSBox) To

func (s *SMSBox) To(number string) []string

To returns the texts sent to number, oldest first, after every delivery in flight finished. number is the normalized E.164 form the plugin sends to.

type Session

type Session struct {

	// Session is the row as the core created it.
	Session auth.Session
	// Token is the raw session token.
	Token auth.Token
	// contains filtered or unexported fields
}

Session is a signed-in session for a test, in every form a request can carry it: a cookie, a bearer header, or a Go context. It comes from Env.SignIn; the raw token is in Token for a test that builds its own request.

func (*Session) Apply

func (s *Session) Apply(req *http.Request)

Apply adds the session cookie to req, and the Origin a page of the app sends, so a mutation passes the origin check.

func (*Session) ApplyBearer

func (s *Session) ApplyBearer(req *http.Request)

ApplyBearer adds the session as an Authorization header, the way an app without cookies sends it; no Origin is needed for a bearer credential.

func (*Session) Authorization

func (s *Session) Authorization() string

Authorization returns the header value a bearer client sends.

func (*Session) BearerContext

func (s *Session) BearerContext(ctx context.Context) context.Context

BearerContext is Context with the token presented as a bearer credential, which the origin check leaves alone and the session policy treats as an app.

func (*Session) Context

func (s *Session) Context(ctx context.Context) context.Context

Context returns ctx as a request from a browser holding the session, so a Go call (a.SignOut(ctx), a plugin's typed method) runs as the signed-in user with the effects recorded on it; auth.RequestEffects reads them.

func (*Session) Cookie

func (s *Session) Cookie() *http.Cookie

Cookie returns the session cookie a browser would hold: the name the net/http transport reads, Secure, path /.

type SweepT

type SweepT interface {
	Helper()
	Errorf(format string, args ...any)
	Fatalf(format string, args ...any)
	Logf(format string, args ...any)
}

SweepT is what SweepFaults needs of a test: *testing.T satisfies it, and so does a recorder in the harness's own tests.

type UserFixture

type UserFixture struct {
	ID         string
	Name       string
	Image      string
	CreatedAt  time.Time
	Identities []Identity
}

UserFixture is a user built in memory and not yet saved, as better-auth's ctx.test.createUser returns one: the test reads or edits its fields, then SaveUser writes it through the real core API. The ID is already final, so a test can refer to the user (in another fixture, an expected value) before it exists.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL