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>
111 lines
3.1 KiB
Go
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)
|
|
}
|