207 lines
5.9 KiB
Go
207 lines
5.9 KiB
Go
// Package contracttest provides reusable adapter conformance suites that
|
|
// exercise storage-agnostic port contracts without depending on one concrete
|
|
// backend implementation.
|
|
package contracttest
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"testing"
|
|
"time"
|
|
|
|
"galaxy/authsession/internal/domain/challenge"
|
|
"galaxy/authsession/internal/domain/common"
|
|
"galaxy/authsession/internal/ports"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// ChallengeStoreFactory constructs a fresh ChallengeStore instance suitable
|
|
// for one isolated contract subtest.
|
|
type ChallengeStoreFactory func(t *testing.T) ports.ChallengeStore
|
|
|
|
// RunChallengeStoreContractTests executes the backend-agnostic ChallengeStore
|
|
// contract suite against newStore.
|
|
func RunChallengeStoreContractTests(t *testing.T, newStore ChallengeStoreFactory) {
|
|
t.Helper()
|
|
|
|
t.Run("create and get", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := newStore(t)
|
|
record := contractConfirmedChallenge(t, time.Unix(1_775_130_000, 0).UTC())
|
|
|
|
require.NoError(t, store.Create(context.Background(), record))
|
|
|
|
got, err := store.Get(context.Background(), record.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, record, got)
|
|
})
|
|
|
|
t.Run("get not found", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := newStore(t)
|
|
|
|
_, err := store.Get(context.Background(), common.ChallengeID("missing-challenge"))
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, ports.ErrNotFound)
|
|
})
|
|
|
|
t.Run("create conflict", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := newStore(t)
|
|
record := contractPendingChallenge(time.Unix(1_775_130_100, 0).UTC())
|
|
|
|
require.NoError(t, store.Create(context.Background(), record))
|
|
|
|
err := store.Create(context.Background(), record)
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, ports.ErrConflict)
|
|
})
|
|
|
|
t.Run("compare and swap success", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := newStore(t)
|
|
now := time.Unix(1_775_130_200, 0).UTC()
|
|
previous := contractPendingChallenge(now)
|
|
next := previous
|
|
next.Status = challenge.StatusSent
|
|
next.DeliveryState = challenge.DeliverySent
|
|
next.Attempts.Send = 1
|
|
next.Abuse.LastAttemptAt = contractTimePointer(now.Add(time.Minute))
|
|
require.NoError(t, next.Validate())
|
|
|
|
require.NoError(t, store.Create(context.Background(), previous))
|
|
require.NoError(t, store.CompareAndSwap(context.Background(), previous, next))
|
|
|
|
got, err := store.Get(context.Background(), previous.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, next, got)
|
|
})
|
|
|
|
t.Run("compare and swap conflict", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := newStore(t)
|
|
now := time.Unix(1_775_130_300, 0).UTC()
|
|
stored := contractPendingChallenge(now)
|
|
previous := stored
|
|
previous.Attempts.Send = 99
|
|
require.NoError(t, previous.Validate())
|
|
next := stored
|
|
next.Status = challenge.StatusSent
|
|
next.DeliveryState = challenge.DeliverySent
|
|
require.NoError(t, next.Validate())
|
|
|
|
require.NoError(t, store.Create(context.Background(), stored))
|
|
|
|
err := store.CompareAndSwap(context.Background(), previous, next)
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, ports.ErrConflict)
|
|
})
|
|
|
|
t.Run("compare and swap not found", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := newStore(t)
|
|
now := time.Unix(1_775_130_400, 0).UTC()
|
|
previous := contractPendingChallenge(now)
|
|
next := previous
|
|
next.Status = challenge.StatusSent
|
|
next.DeliveryState = challenge.DeliverySent
|
|
require.NoError(t, next.Validate())
|
|
|
|
err := store.CompareAndSwap(context.Background(), previous, next)
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, ports.ErrNotFound)
|
|
})
|
|
|
|
t.Run("get returns defensive copies", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := newStore(t)
|
|
record := contractConfirmedChallenge(t, time.Unix(1_775_130_500, 0).UTC())
|
|
|
|
require.NoError(t, store.Create(context.Background(), record))
|
|
|
|
got, err := store.Get(context.Background(), record.ID)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, got.CodeHash)
|
|
got.CodeHash[0] = 0xFF
|
|
if got.Confirmation != nil {
|
|
keyBytes := got.Confirmation.ClientPublicKey.PublicKey()
|
|
if len(keyBytes) > 0 {
|
|
keyBytes[0] = 0xFE
|
|
}
|
|
}
|
|
|
|
again, err := store.Get(context.Background(), record.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, record.CodeHash, again.CodeHash)
|
|
require.NotNil(t, again.Confirmation)
|
|
assert.Equal(t, record.Confirmation.ClientPublicKey.String(), again.Confirmation.ClientPublicKey.String())
|
|
})
|
|
}
|
|
|
|
func contractPendingChallenge(now time.Time) challenge.Challenge {
|
|
record := challenge.Challenge{
|
|
ID: common.ChallengeID("challenge-pending"),
|
|
Email: common.Email("pilot@example.com"),
|
|
CodeHash: []byte("hashed-pending-code"),
|
|
Status: challenge.StatusPendingSend,
|
|
DeliveryState: challenge.DeliveryPending,
|
|
CreatedAt: now,
|
|
ExpiresAt: now.Add(challenge.InitialTTL),
|
|
}
|
|
if err := record.Validate(); err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
return record
|
|
}
|
|
|
|
func contractConfirmedChallenge(t *testing.T, now time.Time) challenge.Challenge {
|
|
t.Helper()
|
|
|
|
clientPublicKey, err := common.NewClientPublicKey(ed25519.PublicKey{
|
|
0, 1, 2, 3, 4, 5, 6, 7,
|
|
8, 9, 10, 11, 12, 13, 14, 15,
|
|
16, 17, 18, 19, 20, 21, 22, 23,
|
|
24, 25, 26, 27, 28, 29, 30, 31,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
record := challenge.Challenge{
|
|
ID: common.ChallengeID("challenge-confirmed"),
|
|
Email: common.Email("pilot@example.com"),
|
|
CodeHash: []byte("hashed-code"),
|
|
Status: challenge.StatusConfirmedPendingExpire,
|
|
DeliveryState: challenge.DeliverySent,
|
|
CreatedAt: now,
|
|
ExpiresAt: now.Add(challenge.ConfirmedRetention),
|
|
Attempts: challenge.AttemptCounters{
|
|
Send: 1,
|
|
Confirm: 2,
|
|
},
|
|
Abuse: challenge.AbuseMetadata{
|
|
LastAttemptAt: contractTimePointer(now.Add(30 * time.Second)),
|
|
},
|
|
Confirmation: &challenge.Confirmation{
|
|
SessionID: common.DeviceSessionID("device-session-1"),
|
|
ClientPublicKey: clientPublicKey,
|
|
ConfirmedAt: now.Add(time.Minute),
|
|
},
|
|
}
|
|
require.NoError(t, record.Validate())
|
|
|
|
return record
|
|
}
|
|
|
|
func contractTimePointer(value time.Time) *time.Time {
|
|
return &value
|
|
}
|