// 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"), PreferredLanguage: "en", 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"), PreferredLanguage: "en", 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 }