storetest

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Aug 20, 2026 License: MIT Imports: 16 Imported by: 0

Documentation

Overview

Package storetest is the conformance suite for the persistence interfaces sulis defines.

Every store interface in this module documents behavior that no compiler can check: ConsumeToken must find-and-mark in one atomic step, UpdateUser must reject a write built from a stale read, DeleteSession must scope its delete to the owning user, ConfirmEnrollment must compare-and-swap, the TOTP replay counter must never move backwards. Those requirements are the difference between a store that merely compiles and one that is safe to authenticate against. This package turns each of them into an executable test so an adopter can prove their own implementation compliant instead of hoping it is.

Using it

Point each Run function at a factory that returns a fresh, empty store:

func TestMyStores(t *testing.T) {
	storetest.RunUserStore(t, func() sulis.UserStore { return myUserStore(t) })
	storetest.RunSessionStore(t, func() sulis.SessionStore { return mySessionStore(t) })
	storetest.RunTokenStore(t, func() sulis.TokenStore { return myTokenStore(t) })
	storetest.RunTOTPStore(t, func() totp.Store { return myTOTPStore(t) })
	storetest.RunRecoveryStore(t, func() recovery.Store { return myRecoveryStore(t) })
}

The passkey interfaces have their own entry points, RunPasskeyStore and RunPasskeyChallengeStore, in the same shape.

What a factory must return

Each factory call must return a store observing no state from any earlier call — an empty database, a truncated schema, a new map. Every subtest calls the factory at least once, and the concurrency subtests call it once per iteration, so a factory backed by a real database should make that reset cheap.

Identifiers, e-mail addresses, and token hashes the suite generates are unique per process run, so a factory that cannot truly reset (a shared development database, say) will still not see collisions between runs. Assertions about counts are always scoped to the specific users the subtest created, never to the whole store.

Concurrency coverage

The atomicity requirements are checked by racing goroutines through a shared start gate and asserting on the aggregate outcome — "exactly one caller succeeded", "the user still has one credential", "the counter did not move backwards". A store whose check-and-mutate is really a separate read then write fails these; a store that holds a lock, a transaction, or a single conditional statement across both passes.

Those subtests repeat many times, since a race that loses is not a race that cannot happen. Run with -race, and pass -short to cut the iteration count when the store is slow (a real database) and the suite is only being smoke-tested.

Scope

The suite asserts on the documented contracts and nothing else. It never inspects storage, never assumes an ordering the interfaces do not promise, and never asserts on timestamps beyond their presence, so it is equally valid against SQL, key-value, and in-memory stores. Package memstore is a reference implementation that passes all of it.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func RunPasskeyChallengeStore

func RunPasskeyChallengeStore(t *testing.T, factory func() passkey.ChallengeStore)

RunPasskeyChallengeStore checks an implementation of passkey.ChallengeStore against the contract documented on that interface.

ConsumeChallenge must fetch and delete in one operation, so that only one caller can ever receive a given challenge: two concurrent finishes of the same ceremony must not both succeed in retrieving it. A store that reads then deletes lets a replayed WebAuthn response be verified twice against the same challenge.

factory must return a fresh, empty store on every call; see the package documentation.

func RunPasskeyStore

func RunPasskeyStore(t *testing.T, factory func() passkey.Store)

RunPasskeyStore checks an implementation of passkey.Store against the contract documented on that interface.

DeleteCredential is where the danger lives. The membership check, the remaining-count check, and the removal must be one atomic operation with respect to any concurrent call for the same user, or two goroutines each deleting one of a user's last two credentials both observe count == 2, both pass the allowLast == false guard, and both succeed — leaving the user with zero credentials, which is precisely the lockout the guard exists to prevent, reached through the guarded path. The suite races that case directly. It also pins the cross-user refusal (ErrPasskeyNotFound, not a silent success) and the bookkeeping UpdateCredentialAfterLogin must persist, which go-webauthn re-checks on every subsequent ceremony: a store that drops BackupState or SignCount breaks the next login rather than this one.

factory must return a fresh, empty store on every call; see the package documentation.

func RunRecoveryStore

func RunRecoveryStore(t *testing.T, factory func() recovery.Store)

RunRecoveryStore checks an implementation of recovery.Store against the contract documented on that interface.

ConsumeCode must find and delete the matching code in one operation. A recovery code is a single-use bypass of every other factor, so a store that looks the code up and then deletes it lets two concurrent presentations of the same code both succeed — one code, two authentications. The suite races that directly, and pins the user scoping: a code is only ever valid for the user it was generated for.

Only hashes are ever stored; the suite passes hashes throughout, as sulis does.

factory must return a fresh, empty store on every call; see the package documentation.

func RunSessionStore

func RunSessionStore(t *testing.T, factory func() sulis.SessionStore)

RunSessionStore checks an implementation of sulis.SessionStore against the contract documented on that interface.

The requirement worth the most here is DeleteSession's scoping: the membership check and the removal must be one operation keyed on both the session ID and the owning user, and zero rows affected — whether the ID does not exist or exists but belongs to someone else — must return ErrSessionNotFound rather than succeeding silently. That is what makes cross-user revocation impossible through Sulis.RevokeSession, which passes the caller's own user ID: a store that ignores the user ID, or that reports success when it deleted nothing, hands an attacker who learns a session ID the power to sign other people out.

Sessions are looked up by token hash, never by raw token; the suite stores only hashes, the same as sulis does.

factory must return a fresh, empty store on every call; see the package documentation.

func RunTOTPStore

func RunTOTPStore(t *testing.T, factory func() totp.Store)

RunTOTPStore checks an implementation of totp.Store against the contract documented on that interface.

Three requirements carry the weight, and all three are invisible to the compiler.

The pending and active slots must stay separate — at most one of each per user — so a stray or racing enrollment can never silently replace an already-verified factor. EnrollPending must refuse when an active credential exists, and it must make that check and its write one operation.

ConfirmEnrollment is a compare-and-swap: it promotes the pending enrollment only while it is still the exact one identified by pendingID. Without that, a racing EnrollPending in the gap between Service reading the pending enrollment and the store committing the promotion would either promote a secret nobody validated a code against, or silently discard a fresh enrollment, with neither caller finding out.

The replay counter must never move backwards. SaveTOTP must reject a save that would lower LastUsedCounter for the active credential with the same ID, and a ConfirmEnrollment that replaces an existing factor must carry the old counter forward when it is the higher of the two. A counter that can regress is a code that can be replayed.

factory must return a fresh, empty store on every call; see the package documentation.

func RunTokenStore

func RunTokenStore(t *testing.T, factory func() sulis.TokenStore)

RunTokenStore checks an implementation of sulis.TokenStore against the contract documented on that interface.

The requirement everything else rests on is ConsumeToken's atomicity: it must find the unused token matching hash AND purpose and mark it used as one operation, so a token can be redeemed exactly once no matter how many callers present it at the same instant. A store that reads the row, checks Used, and then writes hands two concurrent callers the same password-reset token. The suite also pins the two errors apart — ErrTokenNotFound when nothing matches hash+purpose, ErrTokenAlreadyUsed when a match exists but was consumed — because sulis maps them to different outcomes, and pins purpose scoping, without which a two-factor token would be redeemable as a password reset.

The round trip is checked field by field, Token.NonceHash included: a store that persists everything else but drops that column disables magic-link binding for every link it stores, because RedeemMagicLink asks for a binding nonce only when the token it read back carries a NonceHash.

factory must return a fresh, empty store on every call; see the package documentation.

func RunUserStore

func RunUserStore(t *testing.T, factory func() sulis.UserStore)

RunUserStore checks an implementation of sulis.UserStore against the contract documented on that interface.

Two requirements carry the weight here, and both are invisible to the compiler.

Version is optimistic concurrency: UpdateUser must apply the write only while the stored row's version still matches the one the caller read, and must return ErrConcurrentUpdate otherwise. Without it, two flows that each read-modify-write the whole user row silently clobber each other, and the dangerous direction restores a password hash the user just rotated away from.

Email uniqueness must be enforced by the store's write path, on CreateUser and on UpdateUser alike. Version guards one row against a lost update; it says nothing about two different rows racing to claim the same address, and nothing above the interface can make those writes atomic with respect to each other. The concurrency subtests below race both paths, since a store that only rejects duplicates it happens to notice on a prior read passes the sequential checks and fails these.

factory must return a fresh, empty store on every call; see the package documentation.

Types

This section is empty.

Jump to

Keyboard shortcuts

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