Files
tapir/internal/adapters/store/invitations_test.go
T
mathiasandClaude Opus 4.8 8b7ef07ba3 feat(store): invitations table + create/peek/claim methods
Stage-1 email onboarding: Mathias mints an invite, the recipient claims
it to set a Dex password. Invitations exist before their user, so the
table carries no user_id FK and is deliberately outside RLS — the
32-byte crypto-random token is the capability (single-use, time-boxed).

ClaimInvitation consumes atomically (UPDATE ... WHERE used_at IS NULL
... RETURNING) so concurrent claims of one token can't both succeed.
PeekInvitation validates the link for the form without consuming it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:12:40 +02:00

111 lines
3.1 KiB
Go

package store_test
import (
"context"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
)
// resetInvitations clears the invitations table between cases. It is not in the
// shared resetDB TRUNCATE list (invitations is not user-owned and has no FK to
// users), so the invite tests wipe it themselves.
func resetInvitations(t *testing.T, p *pgxpool.Pool) {
t.Helper()
_, err := p.Exec(context.Background(), `TRUNCATE invitations`)
require.NoError(t, err)
}
func TestCreateInvitationReturnsUsableToken(t *testing.T) {
s, p := newStore(t), rawPool(t)
resetInvitations(t, p)
ctx := context.Background()
token, err := s.CreateInvitation(ctx, "new@example.com", time.Hour)
require.NoError(t, err)
require.Len(t, token, 64, "32 random bytes hex-encoded")
// Peek does not consume: the same token previews twice.
email, err := s.PeekInvitation(ctx, token)
require.NoError(t, err)
require.Equal(t, "new@example.com", email)
email, err = s.PeekInvitation(ctx, token)
require.NoError(t, err)
require.Equal(t, "new@example.com", email)
}
func TestCreateInvitationTokensAreUnique(t *testing.T) {
s, p := newStore(t), rawPool(t)
resetInvitations(t, p)
ctx := context.Background()
t1, err := s.CreateInvitation(ctx, "a@example.com", time.Hour)
require.NoError(t, err)
t2, err := s.CreateInvitation(ctx, "b@example.com", time.Hour)
require.NoError(t, err)
require.NotEqual(t, t1, t2)
}
func TestClaimInvitationHappyPath(t *testing.T) {
s, p := newStore(t), rawPool(t)
resetInvitations(t, p)
ctx := context.Background()
token, err := s.CreateInvitation(ctx, "claim@example.com", time.Hour)
require.NoError(t, err)
email, err := s.ClaimInvitation(ctx, token)
require.NoError(t, err)
require.Equal(t, "claim@example.com", email)
}
func TestClaimInvitationIsSingleUse(t *testing.T) {
s, p := newStore(t), rawPool(t)
resetInvitations(t, p)
ctx := context.Background()
token, err := s.CreateInvitation(ctx, "once@example.com", time.Hour)
require.NoError(t, err)
_, err = s.ClaimInvitation(ctx, token)
require.NoError(t, err)
// Second claim fails — already used.
_, err = s.ClaimInvitation(ctx, token)
require.ErrorIs(t, err, store.ErrNotFound)
// And a used token no longer previews.
_, err = s.PeekInvitation(ctx, token)
require.ErrorIs(t, err, store.ErrNotFound)
}
func TestClaimInvitationExpired(t *testing.T) {
s, p := newStore(t), rawPool(t)
resetInvitations(t, p)
ctx := context.Background()
// Negative ttl => already expired.
token, err := s.CreateInvitation(ctx, "old@example.com", -time.Minute)
require.NoError(t, err)
_, err = s.PeekInvitation(ctx, token)
require.ErrorIs(t, err, store.ErrNotFound)
_, err = s.ClaimInvitation(ctx, token)
require.ErrorIs(t, err, store.ErrNotFound)
}
func TestClaimInvitationNotFound(t *testing.T) {
s, p := newStore(t), rawPool(t)
resetInvitations(t, p)
ctx := context.Background()
_, err := s.ClaimInvitation(ctx, "does-not-exist")
require.ErrorIs(t, err, store.ErrNotFound)
_, err = s.PeekInvitation(ctx, "does-not-exist")
require.ErrorIs(t, err, store.ErrNotFound)
}