Adds the persistence layer for the passwordless-auth flow. The token VALUE is never stored — only its sha256 hex digest, mirroring ADR-0021 secret retention via fingerprint. Plaintext is generated server-side, emailed once, and rehashed on consume. Repository methods : Create / GetByHash / MarkConsumed / DeleteExpired. AutoMigrate wired in both PostgresRepository init paths (DSN-built + cfg-built). Tests : - 5 unit tests : token generation shape, URL-safety, uniqueness, hash stability - 5 integration tests (build tag `integration`) : end-to-end against real Postgres, cover the happy path, missing-hash, consume idempotency, expired-cleanup, and the unique-index defensive check
79 lines
2.8 KiB
Go
79 lines
2.8 KiB
Go
package user
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// TestGenerateMagicLinkToken_ShapeAndHashAgree confirms the contract that
|
|
// HashMagicLinkToken(plaintext) == returned hashHex. Without that, the
|
|
// consume handler can never look up what request stored.
|
|
func TestGenerateMagicLinkToken_ShapeAndHashAgree(t *testing.T) {
|
|
plain, hashHex, err := GenerateMagicLinkToken()
|
|
require.NoError(t, err)
|
|
|
|
assert.NotEmpty(t, plain)
|
|
assert.NotEmpty(t, hashHex)
|
|
assert.Len(t, hashHex, 64, "sha256 hex = 64 chars")
|
|
assert.Equal(t, hashHex, HashMagicLinkToken(plain),
|
|
"GenerateMagicLinkToken must return a hash that matches HashMagicLinkToken(plain)")
|
|
}
|
|
|
|
// TestGenerateMagicLinkToken_PlainIsURLSafeBase64 confirms the link can
|
|
// be embedded in a URL without further escaping. RawURLEncoding => no
|
|
// "/", "+", or "=" padding chars.
|
|
func TestGenerateMagicLinkToken_PlainIsURLSafeBase64(t *testing.T) {
|
|
plain, _, err := GenerateMagicLinkToken()
|
|
require.NoError(t, err)
|
|
|
|
for _, bad := range []string{"/", "+", "="} {
|
|
assert.False(t, strings.Contains(plain, bad),
|
|
"plaintext token must not contain %q (URL-unsafe)", bad)
|
|
}
|
|
|
|
decoded, err := base64.RawURLEncoding.DecodeString(plain)
|
|
require.NoError(t, err, "plaintext must round-trip through RawURLEncoding")
|
|
assert.Len(t, decoded, 32, "32 bytes of entropy")
|
|
}
|
|
|
|
// TestGenerateMagicLinkToken_Unique confirms two consecutive calls
|
|
// produce different tokens (not a deterministic seeding bug).
|
|
func TestGenerateMagicLinkToken_Unique(t *testing.T) {
|
|
a, ah, err := GenerateMagicLinkToken()
|
|
require.NoError(t, err)
|
|
b, bh, err := GenerateMagicLinkToken()
|
|
require.NoError(t, err)
|
|
|
|
assert.NotEqual(t, a, b, "plaintexts must differ between calls")
|
|
assert.NotEqual(t, ah, bh, "hashes must differ between calls")
|
|
}
|
|
|
|
// TestHashMagicLinkToken_StableAndCorrect confirms HashMagicLinkToken is
|
|
// a pure function (same input -> same output) AND that it produces the
|
|
// expected sha256 hex digest. Cross-checked against the stdlib so we
|
|
// catch any accidental algorithm swap.
|
|
func TestHashMagicLinkToken_StableAndCorrect(t *testing.T) {
|
|
const sample = "abc123-test-token"
|
|
got1 := HashMagicLinkToken(sample)
|
|
got2 := HashMagicLinkToken(sample)
|
|
assert.Equal(t, got1, got2, "HashMagicLinkToken must be deterministic")
|
|
|
|
sum := sha256.Sum256([]byte(sample))
|
|
want := hex.EncodeToString(sum[:])
|
|
assert.Equal(t, want, got1, "HashMagicLinkToken must be sha256 hex")
|
|
}
|
|
|
|
// TestHashMagicLinkToken_DiffersOnDifferentInput is the tautological
|
|
// counter-test of stability : different inputs -> different outputs.
|
|
// Catches the (unlikely) case where someone replaces the impl with
|
|
// a constant.
|
|
func TestHashMagicLinkToken_DiffersOnDifferentInput(t *testing.T) {
|
|
assert.NotEqual(t, HashMagicLinkToken("a"), HashMagicLinkToken("b"))
|
|
}
|