feat: authsession service
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
package confirmemailcode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/authsession/internal/domain/challenge"
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
"galaxy/authsession/internal/service/shared"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestExecuteReturnsInvalidCodeForThrottledChallengeWithoutConsumingAttempts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deps := newConfirmDeps(t)
|
||||
record := sentChallengeFixture(t, deps.hasher, "challenge-1", "pilot@example.com", "654321", deps.now.Add(-time.Minute), deps.now.Add(time.Minute))
|
||||
record.Status = challenge.StatusDeliveryThrottled
|
||||
record.DeliveryState = challenge.DeliveryThrottled
|
||||
require.NoError(t, record.Validate())
|
||||
require.NoError(t, deps.challengeStore.Create(context.Background(), record))
|
||||
|
||||
service := mustNewConfirmService(t, deps)
|
||||
_, err := service.Execute(context.Background(), Input{
|
||||
ChallengeID: "challenge-1",
|
||||
Code: "654321",
|
||||
ClientPublicKey: publicKeyString(),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, shared.ErrorCodeInvalidCode, shared.CodeOf(err))
|
||||
|
||||
updated, getErr := deps.challengeStore.Get(context.Background(), common.ChallengeID("challenge-1"))
|
||||
require.NoError(t, getErr)
|
||||
assert.Equal(t, 0, updated.Attempts.Confirm)
|
||||
assert.Equal(t, challenge.StatusDeliveryThrottled, updated.Status)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package confirmemailcode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/authsession/internal/domain/challenge"
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
"galaxy/authsession/internal/domain/devicesession"
|
||||
"galaxy/authsession/internal/service/shared"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestExecuteConfirmsChallengeAfterTransientProjectionPublishFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deps := newConfirmDeps(t)
|
||||
deps.publisher.Errors = []error{errors.New("publish failed"), nil}
|
||||
require.NoError(t, deps.userDirectory.SeedExisting(common.Email("pilot@example.com"), common.UserID("user-1")))
|
||||
require.NoError(t, deps.challengeStore.Create(
|
||||
context.Background(),
|
||||
sentChallengeFixture(t, deps.hasher, "challenge-1", "pilot@example.com", "654321", deps.now.Add(-time.Minute), deps.now.Add(time.Minute)),
|
||||
))
|
||||
|
||||
service := mustNewConfirmService(t, deps)
|
||||
result, err := service.Execute(context.Background(), Input{
|
||||
ChallengeID: "challenge-1",
|
||||
Code: "654321",
|
||||
ClientPublicKey: publicKeyString(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "device-session-1", result.DeviceSessionID)
|
||||
require.Len(t, deps.publisher.PublishedSnapshots(), 2)
|
||||
}
|
||||
|
||||
func TestExecuteConfirmedRetryRepublishesAfterTransientProjectionPublishFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deps := newConfirmDeps(t)
|
||||
deps.publisher.Errors = []error{errors.New("publish failed"), nil}
|
||||
key := mustClientPublicKey(t, publicKeyString())
|
||||
require.NoError(t, deps.challengeStore.Create(
|
||||
context.Background(),
|
||||
confirmedChallengeFixture(t, deps.hasher, "challenge-1", "pilot@example.com", "654321", "device-session-1", key, deps.now.Add(-time.Minute), deps.now.Add(time.Minute)),
|
||||
))
|
||||
require.NoError(t, deps.sessionStore.Create(
|
||||
context.Background(),
|
||||
activeSessionFixture("device-session-1", "user-1", key, deps.now.Add(-time.Minute)),
|
||||
))
|
||||
|
||||
service := mustNewConfirmService(t, deps)
|
||||
result, err := service.Execute(context.Background(), Input{
|
||||
ChallengeID: "challenge-1",
|
||||
Code: "654321",
|
||||
ClientPublicKey: publicKeyString(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "device-session-1", result.DeviceSessionID)
|
||||
require.Len(t, deps.publisher.PublishedSnapshots(), 2)
|
||||
}
|
||||
|
||||
func TestExecuteRepairsProjectionOnIdenticalRetryAfterExhaustedPublishRetries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deps := newConfirmDeps(t)
|
||||
deps.publisher.Err = errors.New("publish failed")
|
||||
require.NoError(t, deps.userDirectory.SeedExisting(common.Email("pilot@example.com"), common.UserID("user-1")))
|
||||
require.NoError(t, deps.challengeStore.Create(
|
||||
context.Background(),
|
||||
sentChallengeFixture(t, deps.hasher, "challenge-1", "pilot@example.com", "654321", deps.now.Add(-time.Minute), deps.now.Add(time.Minute)),
|
||||
))
|
||||
|
||||
service := mustNewConfirmService(t, deps)
|
||||
_, err := service.Execute(context.Background(), Input{
|
||||
ChallengeID: "challenge-1",
|
||||
Code: "654321",
|
||||
ClientPublicKey: publicKeyString(),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, shared.ErrorCodeServiceUnavailable, shared.CodeOf(err))
|
||||
require.Len(t, deps.publisher.PublishedSnapshots(), shared.MaxProjectionPublishAttempts)
|
||||
|
||||
sessionRecord, getErr := deps.sessionStore.Get(context.Background(), common.DeviceSessionID("device-session-1"))
|
||||
require.NoError(t, getErr)
|
||||
assert.Equal(t, devicesession.StatusActive, sessionRecord.Status)
|
||||
|
||||
challengeRecord, getErr := deps.challengeStore.Get(context.Background(), common.ChallengeID("challenge-1"))
|
||||
require.NoError(t, getErr)
|
||||
assert.Equal(t, challenge.StatusConfirmedPendingExpire, challengeRecord.Status)
|
||||
require.NotNil(t, challengeRecord.Confirmation)
|
||||
|
||||
deps.publisher.Err = nil
|
||||
|
||||
result, err := service.Execute(context.Background(), Input{
|
||||
ChallengeID: "challenge-1",
|
||||
Code: "654321",
|
||||
ClientPublicKey: publicKeyString(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "device-session-1", result.DeviceSessionID)
|
||||
require.Len(t, deps.publisher.PublishedSnapshots(), shared.MaxProjectionPublishAttempts+1)
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
// Package confirmemailcode implements the public confirm-email-code use case.
|
||||
package confirmemailcode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/authsession/internal/domain/challenge"
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
"galaxy/authsession/internal/domain/devicesession"
|
||||
"galaxy/authsession/internal/domain/sessionlimit"
|
||||
"galaxy/authsession/internal/ports"
|
||||
"galaxy/authsession/internal/service/shared"
|
||||
"galaxy/authsession/internal/telemetry"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
revokeReasonConfirmRace common.RevokeReasonCode = "confirm_race_repair"
|
||||
revokeActorTypeService common.RevokeActorType = "service"
|
||||
revokeActorIDService = "confirmemailcode"
|
||||
)
|
||||
|
||||
// Input describes one public confirm-email-code request.
|
||||
type Input struct {
|
||||
// ChallengeID identifies the challenge that should be confirmed.
|
||||
ChallengeID string
|
||||
|
||||
// Code is the cleartext confirmation code submitted by the caller.
|
||||
Code string
|
||||
|
||||
// ClientPublicKey is the base64-encoded raw 32-byte Ed25519 public key that
|
||||
// should be registered for the created device session.
|
||||
ClientPublicKey string
|
||||
}
|
||||
|
||||
// Result describes one public confirm-email-code response.
|
||||
type Result struct {
|
||||
// DeviceSessionID is the stable identifier of the created or idempotently
|
||||
// recovered device session.
|
||||
DeviceSessionID string
|
||||
}
|
||||
|
||||
// Service executes the public confirm-email-code use case.
|
||||
type Service struct {
|
||||
challengeStore ports.ChallengeStore
|
||||
sessionStore ports.SessionStore
|
||||
userDirectory ports.UserDirectory
|
||||
configProvider ports.ConfigProvider
|
||||
publisher ports.GatewaySessionProjectionPublisher
|
||||
idGenerator ports.IDGenerator
|
||||
codeHasher ports.CodeHasher
|
||||
clock ports.Clock
|
||||
logger *zap.Logger
|
||||
telemetry *telemetry.Runtime
|
||||
}
|
||||
|
||||
// New returns a confirm-email-code service wired to the required ports.
|
||||
func New(
|
||||
challengeStore ports.ChallengeStore,
|
||||
sessionStore ports.SessionStore,
|
||||
userDirectory ports.UserDirectory,
|
||||
configProvider ports.ConfigProvider,
|
||||
publisher ports.GatewaySessionProjectionPublisher,
|
||||
idGenerator ports.IDGenerator,
|
||||
codeHasher ports.CodeHasher,
|
||||
clock ports.Clock,
|
||||
) (*Service, error) {
|
||||
return NewWithTelemetry(
|
||||
challengeStore,
|
||||
sessionStore,
|
||||
userDirectory,
|
||||
configProvider,
|
||||
publisher,
|
||||
idGenerator,
|
||||
codeHasher,
|
||||
clock,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
// NewWithTelemetry returns a confirm-email-code service wired to the required
|
||||
// ports plus the optional Stage-17 telemetry runtime.
|
||||
func NewWithTelemetry(
|
||||
challengeStore ports.ChallengeStore,
|
||||
sessionStore ports.SessionStore,
|
||||
userDirectory ports.UserDirectory,
|
||||
configProvider ports.ConfigProvider,
|
||||
publisher ports.GatewaySessionProjectionPublisher,
|
||||
idGenerator ports.IDGenerator,
|
||||
codeHasher ports.CodeHasher,
|
||||
clock ports.Clock,
|
||||
telemetryRuntime *telemetry.Runtime,
|
||||
) (*Service, error) {
|
||||
return NewWithObservability(
|
||||
challengeStore,
|
||||
sessionStore,
|
||||
userDirectory,
|
||||
configProvider,
|
||||
publisher,
|
||||
idGenerator,
|
||||
codeHasher,
|
||||
clock,
|
||||
nil,
|
||||
telemetryRuntime,
|
||||
)
|
||||
}
|
||||
|
||||
// NewWithObservability returns a confirm-email-code service wired to the
|
||||
// required ports plus optional structured logging and telemetry dependencies.
|
||||
func NewWithObservability(
|
||||
challengeStore ports.ChallengeStore,
|
||||
sessionStore ports.SessionStore,
|
||||
userDirectory ports.UserDirectory,
|
||||
configProvider ports.ConfigProvider,
|
||||
publisher ports.GatewaySessionProjectionPublisher,
|
||||
idGenerator ports.IDGenerator,
|
||||
codeHasher ports.CodeHasher,
|
||||
clock ports.Clock,
|
||||
logger *zap.Logger,
|
||||
telemetryRuntime *telemetry.Runtime,
|
||||
) (*Service, error) {
|
||||
switch {
|
||||
case challengeStore == nil:
|
||||
return nil, fmt.Errorf("confirmemailcode: challenge store must not be nil")
|
||||
case sessionStore == nil:
|
||||
return nil, fmt.Errorf("confirmemailcode: session store must not be nil")
|
||||
case userDirectory == nil:
|
||||
return nil, fmt.Errorf("confirmemailcode: user directory must not be nil")
|
||||
case configProvider == nil:
|
||||
return nil, fmt.Errorf("confirmemailcode: config provider must not be nil")
|
||||
case publisher == nil:
|
||||
return nil, fmt.Errorf("confirmemailcode: projection publisher must not be nil")
|
||||
case idGenerator == nil:
|
||||
return nil, fmt.Errorf("confirmemailcode: id generator must not be nil")
|
||||
case codeHasher == nil:
|
||||
return nil, fmt.Errorf("confirmemailcode: code hasher must not be nil")
|
||||
case clock == nil:
|
||||
return nil, fmt.Errorf("confirmemailcode: clock must not be nil")
|
||||
default:
|
||||
return &Service{
|
||||
challengeStore: challengeStore,
|
||||
sessionStore: sessionStore,
|
||||
userDirectory: userDirectory,
|
||||
configProvider: configProvider,
|
||||
publisher: publisher,
|
||||
idGenerator: idGenerator,
|
||||
codeHasher: codeHasher,
|
||||
clock: clock,
|
||||
logger: namedLogger(logger, "confirm_email_code"),
|
||||
telemetry: telemetryRuntime,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Execute validates one challenge confirmation attempt, creates a device
|
||||
// session when policy allows it, and handles short-window idempotent retries.
|
||||
func (s *Service) Execute(ctx context.Context, input Input) (result Result, err error) {
|
||||
logFields := []zap.Field{
|
||||
zap.String("component", "service"),
|
||||
zap.String("use_case", "confirm_email_code"),
|
||||
}
|
||||
defer func() {
|
||||
outcome := string(telemetry.ConfirmEmailCodeOutcomeSuccess)
|
||||
if err != nil {
|
||||
outcome = shared.CodeOf(err)
|
||||
if outcome == "" {
|
||||
outcome = shared.ErrorCodeServiceUnavailable
|
||||
}
|
||||
}
|
||||
s.telemetry.RecordConfirmEmailCode(ctx, outcome)
|
||||
logFields = append(logFields, zap.String("outcome", outcome))
|
||||
if result.DeviceSessionID != "" {
|
||||
logFields = append(logFields, zap.String("device_session_id", result.DeviceSessionID))
|
||||
}
|
||||
shared.LogServiceOutcome(s.logger, ctx, "confirm email code completed", err, logFields...)
|
||||
}()
|
||||
|
||||
challengeID, err := shared.ParseChallengeID(input.ChallengeID)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
logFields = append(logFields, zap.String("challenge_id", challengeID.String()))
|
||||
code, err := shared.ParseRequiredCode(input.Code)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
clientPublicKey, err := shared.ParseClientPublicKey(input.ClientPublicKey)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
for attempt := 0; attempt < shared.MaxCompareAndSwapRetries; attempt++ {
|
||||
current, err := s.challengeStore.Get(ctx, challengeID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return Result{}, shared.ChallengeNotFound()
|
||||
default:
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
}
|
||||
|
||||
now := s.clock.Now().UTC()
|
||||
if expired, err := s.ensureChallengeNotExpired(ctx, current, now); err != nil {
|
||||
if errors.Is(err, ports.ErrConflict) {
|
||||
continue
|
||||
}
|
||||
return Result{}, err
|
||||
} else if expired {
|
||||
return Result{}, shared.ChallengeExpired()
|
||||
}
|
||||
|
||||
switch {
|
||||
case current.Status.IsConfirmedRetryState():
|
||||
return s.handleConfirmedRetry(ctx, current, code, clientPublicKey)
|
||||
case !current.Status.AcceptsFreshConfirm():
|
||||
return Result{}, shared.InvalidCode()
|
||||
}
|
||||
|
||||
match, err := s.codeHasher.Compare(current.CodeHash, code)
|
||||
if err != nil {
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
if !match {
|
||||
if err := s.recordInvalidConfirmAttempt(ctx, current, now); err != nil {
|
||||
if errors.Is(err, ports.ErrConflict) {
|
||||
continue
|
||||
}
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
return Result{}, shared.InvalidCode()
|
||||
}
|
||||
|
||||
ensureUserResult, err := s.userDirectory.EnsureUserByEmail(ctx, current.Email)
|
||||
if err != nil {
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
if err := ensureUserResult.Validate(); err != nil {
|
||||
return Result{}, shared.InternalError(err)
|
||||
}
|
||||
s.telemetry.RecordUserDirectoryOutcome(ctx, "ensure_user_by_email", string(ensureUserResult.Outcome))
|
||||
if !ensureUserResult.UserID.IsZero() {
|
||||
logFields = append(logFields, zap.String("user_id", ensureUserResult.UserID.String()))
|
||||
}
|
||||
if ensureUserResult.Outcome == ports.EnsureUserOutcomeBlocked {
|
||||
if err := s.markChallengeFailed(ctx, current, now); err != nil {
|
||||
if errors.Is(err, ports.ErrConflict) {
|
||||
continue
|
||||
}
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
return Result{}, shared.BlockedByPolicy()
|
||||
}
|
||||
|
||||
limitConfig, err := s.configProvider.LoadSessionLimit(ctx)
|
||||
if err != nil {
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
decision, err := s.evaluateSessionLimit(ctx, ensureUserResult.UserID, limitConfig)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if decision.Kind == sessionlimit.KindExceeded {
|
||||
s.telemetry.RecordSessionLimitRejection(ctx)
|
||||
return Result{}, shared.SessionLimitExceeded()
|
||||
}
|
||||
|
||||
sessionRecord, err := s.createSession(ctx, ensureUserResult.UserID, clientPublicKey, now)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
next := current
|
||||
next.Status = challenge.StatusConfirmedPendingExpire
|
||||
next.ExpiresAt = now.Add(challenge.ConfirmedRetention)
|
||||
next.Abuse.LastAttemptAt = &now
|
||||
next.Confirmation = &challenge.Confirmation{
|
||||
SessionID: sessionRecord.ID,
|
||||
ClientPublicKey: clientPublicKey,
|
||||
ConfirmedAt: now,
|
||||
}
|
||||
if err := next.Validate(); err != nil {
|
||||
s.bestEffortRevokeSupersededSession(ctx, sessionRecord)
|
||||
return Result{}, shared.InternalError(err)
|
||||
}
|
||||
|
||||
if err := s.challengeStore.CompareAndSwap(ctx, current, next); err != nil {
|
||||
if errors.Is(err, ports.ErrConflict) {
|
||||
return s.handleCreateSessionCASConflict(ctx, challengeID, code, clientPublicKey, sessionRecord)
|
||||
}
|
||||
|
||||
s.bestEffortRevokeSupersededSession(ctx, sessionRecord)
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
|
||||
// Publish the currently stored session view so a concurrent revoke/block
|
||||
// cannot overwrite source of truth with a stale active projection.
|
||||
currentSession, err := s.sessionStore.Get(ctx, sessionRecord.ID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return Result{}, shared.InternalError(fmt.Errorf("confirmemailcode: newly created session %q was not found", sessionRecord.ID))
|
||||
default:
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
}
|
||||
if err := s.publishSession(ctx, currentSession, "confirm_email_code"); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
return Result{DeviceSessionID: currentSession.ID.String()}, nil
|
||||
}
|
||||
|
||||
return Result{}, shared.ServiceUnavailable(fmt.Errorf("confirmemailcode: compare-and-swap retry limit exceeded"))
|
||||
}
|
||||
|
||||
func (s *Service) ensureChallengeNotExpired(ctx context.Context, current challenge.Challenge, now time.Time) (bool, error) {
|
||||
if current.IsExpiredAt(now) {
|
||||
if current.Status != challenge.StatusExpired && current.Status.CanTransitionTo(challenge.StatusExpired) {
|
||||
next := current
|
||||
next.Status = challenge.StatusExpired
|
||||
next.Abuse.LastAttemptAt = &now
|
||||
next.Confirmation = nil
|
||||
if err := next.Validate(); err != nil {
|
||||
return true, shared.InternalError(err)
|
||||
}
|
||||
if err := s.challengeStore.CompareAndSwap(ctx, current, next); err != nil {
|
||||
if !errors.Is(err, ports.ErrConflict) {
|
||||
return true, shared.ServiceUnavailable(err)
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *Service) handleConfirmedRetry(ctx context.Context, current challenge.Challenge, code string, clientPublicKey common.ClientPublicKey) (Result, error) {
|
||||
match, err := s.codeHasher.Compare(current.CodeHash, code)
|
||||
if err != nil {
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
if !match {
|
||||
return Result{}, shared.InvalidCode()
|
||||
}
|
||||
if current.Confirmation == nil {
|
||||
return Result{}, shared.InternalError(fmt.Errorf("confirmemailcode: confirmed challenge is missing confirmation metadata"))
|
||||
}
|
||||
if current.Confirmation.ClientPublicKey.String() != clientPublicKey.String() {
|
||||
return Result{}, shared.InvalidCode()
|
||||
}
|
||||
|
||||
record, err := s.sessionStore.Get(ctx, current.Confirmation.SessionID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return Result{}, shared.InternalError(fmt.Errorf("confirmemailcode: confirmed session %q was not found", current.Confirmation.SessionID))
|
||||
default:
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
}
|
||||
if err := s.publishSession(ctx, record, "confirm_email_code_retry"); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
return Result{DeviceSessionID: record.ID.String()}, nil
|
||||
}
|
||||
|
||||
func (s *Service) recordInvalidConfirmAttempt(ctx context.Context, current challenge.Challenge, now time.Time) error {
|
||||
next := current
|
||||
next.Attempts.Confirm++
|
||||
next.Abuse.LastAttemptAt = &now
|
||||
if next.Attempts.Confirm >= challenge.MaxInvalidConfirmAttempts {
|
||||
next.Status = challenge.StatusFailed
|
||||
}
|
||||
if err := next.Validate(); err != nil {
|
||||
return shared.InternalError(err)
|
||||
}
|
||||
|
||||
if err := s.challengeStore.CompareAndSwap(ctx, current, next); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrConflict):
|
||||
return err
|
||||
default:
|
||||
return shared.ServiceUnavailable(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) markChallengeFailed(ctx context.Context, current challenge.Challenge, now time.Time) error {
|
||||
next := current
|
||||
next.Status = challenge.StatusFailed
|
||||
next.Abuse.LastAttemptAt = &now
|
||||
if err := next.Validate(); err != nil {
|
||||
return shared.InternalError(err)
|
||||
}
|
||||
|
||||
if err := s.challengeStore.CompareAndSwap(ctx, current, next); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrConflict):
|
||||
return err
|
||||
default:
|
||||
return shared.ServiceUnavailable(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) evaluateSessionLimit(ctx context.Context, userID common.UserID, config ports.SessionLimitConfig) (sessionlimit.Decision, error) {
|
||||
activeSessionCount, err := s.sessionStore.CountActiveByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return sessionlimit.Decision{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
|
||||
decision, err := shared.EvaluateSessionLimit(config, activeSessionCount)
|
||||
if err != nil {
|
||||
return sessionlimit.Decision{}, err
|
||||
}
|
||||
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func (s *Service) createSession(ctx context.Context, userID common.UserID, clientPublicKey common.ClientPublicKey, now time.Time) (devicesession.Session, error) {
|
||||
for attempt := 0; attempt < shared.MaxCompareAndSwapRetries; attempt++ {
|
||||
deviceSessionID, err := s.idGenerator.NewDeviceSessionID()
|
||||
if err != nil {
|
||||
return devicesession.Session{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
|
||||
record := devicesession.Session{
|
||||
ID: deviceSessionID,
|
||||
UserID: userID,
|
||||
ClientPublicKey: clientPublicKey,
|
||||
Status: devicesession.StatusActive,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return devicesession.Session{}, shared.InternalError(err)
|
||||
}
|
||||
|
||||
if err := s.sessionStore.Create(ctx, record); err != nil {
|
||||
if errors.Is(err, ports.ErrConflict) {
|
||||
continue
|
||||
}
|
||||
return devicesession.Session{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
s.telemetry.RecordSessionCreated(ctx)
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
return devicesession.Session{}, shared.ServiceUnavailable(fmt.Errorf("confirmemailcode: session id conflict retry limit exceeded"))
|
||||
}
|
||||
|
||||
func (s *Service) handleCreateSessionCASConflict(
|
||||
ctx context.Context,
|
||||
challengeID common.ChallengeID,
|
||||
code string,
|
||||
clientPublicKey common.ClientPublicKey,
|
||||
createdSession devicesession.Session,
|
||||
) (Result, error) {
|
||||
defer s.bestEffortRevokeSupersededSession(ctx, createdSession)
|
||||
|
||||
current, err := s.challengeStore.Get(ctx, challengeID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ports.ErrNotFound) {
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
|
||||
if current.Status != challenge.StatusConfirmedPendingExpire || current.Confirmation == nil {
|
||||
return Result{}, shared.ServiceUnavailable(fmt.Errorf("confirmemailcode: challenge %q changed to unexpected status %q after create", challengeID, current.Status))
|
||||
}
|
||||
|
||||
match, err := s.codeHasher.Compare(current.CodeHash, code)
|
||||
if err != nil {
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
if !match || current.Confirmation.ClientPublicKey.String() != clientPublicKey.String() {
|
||||
return Result{}, shared.ServiceUnavailable(fmt.Errorf("confirmemailcode: challenge %q was confirmed by a different payload", challengeID))
|
||||
}
|
||||
|
||||
winningSession, err := s.sessionStore.Get(ctx, current.Confirmation.SessionID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return Result{}, shared.InternalError(fmt.Errorf("confirmemailcode: winning session %q was not found", current.Confirmation.SessionID))
|
||||
default:
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
}
|
||||
if err := s.publishSession(ctx, winningSession, "confirm_email_code_race_winner"); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
return Result{DeviceSessionID: winningSession.ID.String()}, nil
|
||||
}
|
||||
|
||||
func (s *Service) bestEffortRevokeSupersededSession(ctx context.Context, record devicesession.Session) {
|
||||
revocation := devicesession.Revocation{
|
||||
At: s.clock.Now().UTC(),
|
||||
ReasonCode: revokeReasonConfirmRace,
|
||||
ActorType: revokeActorTypeService,
|
||||
ActorID: revokeActorIDService,
|
||||
}
|
||||
if err := revocation.Validate(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
revokeResult, err := s.sessionStore.Revoke(ctx, ports.RevokeSessionInput{
|
||||
DeviceSessionID: record.ID,
|
||||
Revocation: revocation,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Warn(
|
||||
"best-effort superseded session revoke failed",
|
||||
zap.String("component", "service"),
|
||||
zap.String("use_case", "confirm_email_code"),
|
||||
zap.String("operation", "confirm_email_code_race_cleanup"),
|
||||
zap.String("device_session_id", record.ID.String()),
|
||||
zap.String("reason_code", revocation.ReasonCode.String()),
|
||||
zap.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
if err := revokeResult.Validate(); err != nil {
|
||||
s.logger.Warn(
|
||||
"best-effort superseded session revoke produced invalid result",
|
||||
zap.String("component", "service"),
|
||||
zap.String("use_case", "confirm_email_code"),
|
||||
zap.String("operation", "confirm_email_code_race_cleanup"),
|
||||
zap.String("device_session_id", record.ID.String()),
|
||||
zap.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
if revokeResult.Outcome == ports.RevokeSessionOutcomeRevoked {
|
||||
s.telemetry.RecordSessionRevocations(ctx, "confirm_email_code_race_cleanup", revocation.ReasonCode.String(), 1)
|
||||
}
|
||||
|
||||
snapshot, err := shared.ToGatewayProjectionSnapshot(revokeResult.Session)
|
||||
if err != nil {
|
||||
s.logger.Warn(
|
||||
"best-effort superseded session snapshot mapping failed",
|
||||
zap.String("component", "service"),
|
||||
zap.String("use_case", "confirm_email_code"),
|
||||
zap.String("operation", "confirm_email_code_race_cleanup"),
|
||||
zap.String("device_session_id", revokeResult.Session.ID.String()),
|
||||
zap.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
if err := shared.PublishProjectionSnapshotWithTelemetry(ctx, s.publisher, snapshot, s.telemetry, "confirm_email_code_race_cleanup"); err != nil {
|
||||
s.logger.Warn(
|
||||
"best-effort superseded session publish failed",
|
||||
zap.String("component", "service"),
|
||||
zap.String("use_case", "confirm_email_code"),
|
||||
zap.String("operation", "confirm_email_code_race_cleanup"),
|
||||
zap.String("device_session_id", revokeResult.Session.ID.String()),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) publishSession(ctx context.Context, record devicesession.Session, operation string) error {
|
||||
return shared.PublishSessionProjectionWithTelemetry(ctx, s.publisher, record, s.telemetry, operation)
|
||||
}
|
||||
|
||||
func namedLogger(logger *zap.Logger, name string) *zap.Logger {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
|
||||
return logger.Named(name)
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
package confirmemailcode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/authsession/internal/domain/challenge"
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
"galaxy/authsession/internal/domain/devicesession"
|
||||
"galaxy/authsession/internal/domain/userresolution"
|
||||
"galaxy/authsession/internal/service/shared"
|
||||
"galaxy/authsession/internal/testkit"
|
||||
)
|
||||
|
||||
func TestExecuteConfirmsChallengeForExistingUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deps := newConfirmDeps(t)
|
||||
if err := deps.userDirectory.SeedExisting(common.Email("pilot@example.com"), common.UserID("user-1")); err != nil {
|
||||
require.Failf(t, "test failed", "SeedExisting() returned error: %v", err)
|
||||
}
|
||||
if err := deps.challengeStore.Create(context.Background(), sentChallengeFixture(t, deps.hasher, "challenge-1", "pilot@example.com", "654321", deps.now.Add(-time.Minute), deps.now.Add(time.Minute))); err != nil {
|
||||
require.Failf(t, "test failed", "Create() returned error: %v", err)
|
||||
}
|
||||
|
||||
service := mustNewConfirmService(t, deps)
|
||||
result, err := service.Execute(context.Background(), Input{
|
||||
ChallengeID: "challenge-1",
|
||||
Code: "654321",
|
||||
ClientPublicKey: publicKeyString(),
|
||||
})
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "Execute() returned error: %v", err)
|
||||
}
|
||||
if result.DeviceSessionID != "device-session-1" {
|
||||
require.Failf(t, "test failed", "Execute().DeviceSessionID = %q, want %q", result.DeviceSessionID, "device-session-1")
|
||||
}
|
||||
|
||||
record, err := deps.sessionStore.Get(context.Background(), common.DeviceSessionID("device-session-1"))
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "Get() returned error: %v", err)
|
||||
}
|
||||
if record.Status != devicesession.StatusActive {
|
||||
require.Failf(t, "test failed", "session status = %q, want %q", record.Status, devicesession.StatusActive)
|
||||
}
|
||||
|
||||
challengeRecord, err := deps.challengeStore.Get(context.Background(), common.ChallengeID("challenge-1"))
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "Get() returned error: %v", err)
|
||||
}
|
||||
if challengeRecord.Status != challenge.StatusConfirmedPendingExpire || challengeRecord.Confirmation == nil {
|
||||
require.Failf(t, "test failed", "challenge status = %q, confirmation = %+v", challengeRecord.Status, challengeRecord.Confirmation)
|
||||
}
|
||||
if len(deps.publisher.PublishedSnapshots()) != 1 {
|
||||
require.Failf(t, "test failed", "PublishedSnapshots() length = %d, want 1", len(deps.publisher.PublishedSnapshots()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteConfirmsChallengeByCreatingUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deps := newConfirmDeps(t)
|
||||
if err := deps.userDirectory.QueueCreatedUserIDs(common.UserID("user-created")); err != nil {
|
||||
require.Failf(t, "test failed", "QueueCreatedUserIDs() returned error: %v", err)
|
||||
}
|
||||
if err := deps.challengeStore.Create(context.Background(), sentChallengeFixture(t, deps.hasher, "challenge-1", "new@example.com", "654321", deps.now.Add(-time.Minute), deps.now.Add(time.Minute))); err != nil {
|
||||
require.Failf(t, "test failed", "Create() returned error: %v", err)
|
||||
}
|
||||
|
||||
service := mustNewConfirmService(t, deps)
|
||||
result, err := service.Execute(context.Background(), Input{
|
||||
ChallengeID: "challenge-1",
|
||||
Code: "654321",
|
||||
ClientPublicKey: publicKeyString(),
|
||||
})
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "Execute() returned error: %v", err)
|
||||
}
|
||||
if result.DeviceSessionID != "device-session-1" {
|
||||
require.Failf(t, "test failed", "Execute().DeviceSessionID = %q, want %q", result.DeviceSessionID, "device-session-1")
|
||||
}
|
||||
|
||||
record, err := deps.sessionStore.Get(context.Background(), common.DeviceSessionID("device-session-1"))
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "Get() returned error: %v", err)
|
||||
}
|
||||
if record.UserID != common.UserID("user-created") {
|
||||
require.Failf(t, "test failed", "session user id = %q, want %q", record.UserID, common.UserID("user-created"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteConfirmsSuppressedChallenge(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deps := newConfirmDeps(t)
|
||||
if err := deps.userDirectory.SeedExisting(common.Email("pilot@example.com"), common.UserID("user-1")); err != nil {
|
||||
require.Failf(t, "test failed", "SeedExisting() returned error: %v", err)
|
||||
}
|
||||
record := sentChallengeFixture(t, deps.hasher, "challenge-1", "pilot@example.com", "654321", deps.now.Add(-time.Minute), deps.now.Add(time.Minute))
|
||||
record.Status = challenge.StatusDeliverySuppressed
|
||||
record.DeliveryState = challenge.DeliverySuppressed
|
||||
if err := record.Validate(); err != nil {
|
||||
require.Failf(t, "test failed", "Validate() returned error: %v", err)
|
||||
}
|
||||
if err := deps.challengeStore.Create(context.Background(), record); err != nil {
|
||||
require.Failf(t, "test failed", "Create() returned error: %v", err)
|
||||
}
|
||||
|
||||
service := mustNewConfirmService(t, deps)
|
||||
result, err := service.Execute(context.Background(), Input{
|
||||
ChallengeID: "challenge-1",
|
||||
Code: "654321",
|
||||
ClientPublicKey: publicKeyString(),
|
||||
})
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "Execute() returned error: %v", err)
|
||||
}
|
||||
if result.DeviceSessionID != "device-session-1" {
|
||||
require.Failf(t, "test failed", "Execute().DeviceSessionID = %q, want %q", result.DeviceSessionID, "device-session-1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteReturnsChallengeNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service := mustNewConfirmService(t, newConfirmDeps(t))
|
||||
|
||||
_, err := service.Execute(context.Background(), Input{
|
||||
ChallengeID: "missing",
|
||||
Code: "654321",
|
||||
ClientPublicKey: publicKeyString(),
|
||||
})
|
||||
if shared.CodeOf(err) != shared.ErrorCodeChallengeNotFound {
|
||||
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeChallengeNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteReturnsChallengeExpiredAndMarksExpired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deps := newConfirmDeps(t)
|
||||
if err := deps.challengeStore.Create(context.Background(), sentChallengeFixture(t, deps.hasher, "challenge-1", "pilot@example.com", "654321", deps.now.Add(-2*time.Minute), deps.now.Add(-time.Second))); err != nil {
|
||||
require.Failf(t, "test failed", "Create() returned error: %v", err)
|
||||
}
|
||||
|
||||
service := mustNewConfirmService(t, deps)
|
||||
|
||||
_, err := service.Execute(context.Background(), Input{
|
||||
ChallengeID: "challenge-1",
|
||||
Code: "654321",
|
||||
ClientPublicKey: publicKeyString(),
|
||||
})
|
||||
if shared.CodeOf(err) != shared.ErrorCodeChallengeExpired {
|
||||
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeChallengeExpired)
|
||||
}
|
||||
|
||||
record, err := deps.challengeStore.Get(context.Background(), common.ChallengeID("challenge-1"))
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "Get() returned error: %v", err)
|
||||
}
|
||||
if record.Status != challenge.StatusExpired {
|
||||
require.Failf(t, "test failed", "challenge status = %q, want %q", record.Status, challenge.StatusExpired)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteReturnsChallengeExpiredForConfirmedChallengeAfterRetentionWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deps := newConfirmDeps(t)
|
||||
key, err := shared.ParseClientPublicKey(publicKeyString())
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "ParseClientPublicKey() returned error: %v", err)
|
||||
}
|
||||
record := confirmedChallengeFixture(
|
||||
t,
|
||||
deps.hasher,
|
||||
"challenge-1",
|
||||
"pilot@example.com",
|
||||
"654321",
|
||||
"device-session-1",
|
||||
key,
|
||||
deps.now.Add(-2*challenge.ConfirmedRetention),
|
||||
deps.now.Add(-time.Second),
|
||||
)
|
||||
if err := deps.challengeStore.Create(context.Background(), record); err != nil {
|
||||
require.Failf(t, "test failed", "Create() returned error: %v", err)
|
||||
}
|
||||
|
||||
service := mustNewConfirmService(t, deps)
|
||||
_, err = service.Execute(context.Background(), Input{
|
||||
ChallengeID: "challenge-1",
|
||||
Code: "654321",
|
||||
ClientPublicKey: publicKeyString(),
|
||||
})
|
||||
if shared.CodeOf(err) != shared.ErrorCodeChallengeExpired {
|
||||
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeChallengeExpired)
|
||||
}
|
||||
|
||||
updated, err := deps.challengeStore.Get(context.Background(), common.ChallengeID("challenge-1"))
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "Get() returned error: %v", err)
|
||||
}
|
||||
if updated.Status != challenge.StatusExpired {
|
||||
require.Failf(t, "test failed", "challenge status = %q, want %q", updated.Status, challenge.StatusExpired)
|
||||
}
|
||||
if updated.Confirmation != nil {
|
||||
require.Failf(t, "test failed", "Confirmation = %+v, want nil after expiration", updated.Confirmation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteReturnsInvalidClientPublicKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service := mustNewConfirmService(t, newConfirmDeps(t))
|
||||
|
||||
_, err := service.Execute(context.Background(), Input{
|
||||
ChallengeID: "challenge-1",
|
||||
Code: "654321",
|
||||
ClientPublicKey: "invalid",
|
||||
})
|
||||
if shared.CodeOf(err) != shared.ErrorCodeInvalidClientPublicKey {
|
||||
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeInvalidClientPublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteInvalidCodeIncrementsAttempts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deps := newConfirmDeps(t)
|
||||
if err := deps.challengeStore.Create(context.Background(), sentChallengeFixture(t, deps.hasher, "challenge-1", "pilot@example.com", "654321", deps.now.Add(-time.Minute), deps.now.Add(time.Minute))); err != nil {
|
||||
require.Failf(t, "test failed", "Create() returned error: %v", err)
|
||||
}
|
||||
|
||||
service := mustNewConfirmService(t, deps)
|
||||
_, err := service.Execute(context.Background(), Input{
|
||||
ChallengeID: "challenge-1",
|
||||
Code: "000000",
|
||||
ClientPublicKey: publicKeyString(),
|
||||
})
|
||||
if shared.CodeOf(err) != shared.ErrorCodeInvalidCode {
|
||||
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeInvalidCode)
|
||||
}
|
||||
|
||||
record, err := deps.challengeStore.Get(context.Background(), common.ChallengeID("challenge-1"))
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "Get() returned error: %v", err)
|
||||
}
|
||||
if record.Attempts.Confirm != 1 {
|
||||
require.Failf(t, "test failed", "Attempts.Confirm = %d, want 1", record.Attempts.Confirm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteFifthInvalidAttemptMarksChallengeFailed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deps := newConfirmDeps(t)
|
||||
record := sentChallengeFixture(t, deps.hasher, "challenge-1", "pilot@example.com", "654321", deps.now.Add(-time.Minute), deps.now.Add(time.Minute))
|
||||
record.Attempts.Confirm = 4
|
||||
if err := deps.challengeStore.Create(context.Background(), record); err != nil {
|
||||
require.Failf(t, "test failed", "Create() returned error: %v", err)
|
||||
}
|
||||
|
||||
service := mustNewConfirmService(t, deps)
|
||||
_, err := service.Execute(context.Background(), Input{
|
||||
ChallengeID: "challenge-1",
|
||||
Code: "000000",
|
||||
ClientPublicKey: publicKeyString(),
|
||||
})
|
||||
if shared.CodeOf(err) != shared.ErrorCodeInvalidCode {
|
||||
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeInvalidCode)
|
||||
}
|
||||
|
||||
updated, err := deps.challengeStore.Get(context.Background(), common.ChallengeID("challenge-1"))
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "Get() returned error: %v", err)
|
||||
}
|
||||
if updated.Status != challenge.StatusFailed {
|
||||
require.Failf(t, "test failed", "challenge status = %q, want %q", updated.Status, challenge.StatusFailed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteDoesNotCreateSessionAfterTooManyAttempts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deps := newConfirmDeps(t)
|
||||
if err := deps.userDirectory.SeedExisting(common.Email("pilot@example.com"), common.UserID("user-1")); err != nil {
|
||||
require.Failf(t, "test failed", "SeedExisting() returned error: %v", err)
|
||||
}
|
||||
record := sentChallengeFixture(t, deps.hasher, "challenge-1", "pilot@example.com", "654321", deps.now.Add(-time.Minute), deps.now.Add(time.Minute))
|
||||
record.Attempts.Confirm = challenge.MaxInvalidConfirmAttempts
|
||||
record.Status = challenge.StatusFailed
|
||||
if err := record.Validate(); err != nil {
|
||||
require.Failf(t, "test failed", "Validate() returned error: %v", err)
|
||||
}
|
||||
if err := deps.challengeStore.Create(context.Background(), record); err != nil {
|
||||
require.Failf(t, "test failed", "Create() returned error: %v", err)
|
||||
}
|
||||
|
||||
service := mustNewConfirmService(t, deps)
|
||||
_, err := service.Execute(context.Background(), Input{
|
||||
ChallengeID: "challenge-1",
|
||||
Code: "654321",
|
||||
ClientPublicKey: publicKeyString(),
|
||||
})
|
||||
if shared.CodeOf(err) != shared.ErrorCodeInvalidCode {
|
||||
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeInvalidCode)
|
||||
}
|
||||
|
||||
if got, err := deps.sessionStore.CountActiveByUserID(context.Background(), common.UserID("user-1")); err != nil {
|
||||
require.Failf(t, "test failed", "CountActiveByUserID() returned error: %v", err)
|
||||
} else if got != 0 {
|
||||
require.Failf(t, "test failed", "CountActiveByUserID() = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteReturnsSameSessionIDForIdempotentRetryAndRepublishes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deps := newConfirmDeps(t)
|
||||
key, err := shared.ParseClientPublicKey(publicKeyString())
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "ParseClientPublicKey() returned error: %v", err)
|
||||
}
|
||||
record := confirmedChallengeFixture(t, deps.hasher, "challenge-1", "pilot@example.com", "654321", "device-session-1", key, deps.now.Add(-time.Minute), deps.now.Add(time.Minute))
|
||||
if err := deps.challengeStore.Create(context.Background(), record); err != nil {
|
||||
require.Failf(t, "test failed", "Create() returned error: %v", err)
|
||||
}
|
||||
if err := deps.sessionStore.Create(context.Background(), activeSessionFixture("device-session-1", "user-1", key, deps.now.Add(-time.Minute))); err != nil {
|
||||
require.Failf(t, "test failed", "Create() returned error: %v", err)
|
||||
}
|
||||
|
||||
service := mustNewConfirmService(t, deps)
|
||||
result, err := service.Execute(context.Background(), Input{
|
||||
ChallengeID: "challenge-1",
|
||||
Code: "654321",
|
||||
ClientPublicKey: publicKeyString(),
|
||||
})
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "Execute() returned error: %v", err)
|
||||
}
|
||||
if result.DeviceSessionID != "device-session-1" {
|
||||
require.Failf(t, "test failed", "Execute().DeviceSessionID = %q, want %q", result.DeviceSessionID, "device-session-1")
|
||||
}
|
||||
if len(deps.publisher.PublishedSnapshots()) != 1 {
|
||||
require.Failf(t, "test failed", "PublishedSnapshots() length = %d, want 1", len(deps.publisher.PublishedSnapshots()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteReturnsInvalidCodeForDifferentKeyDuringIdempotentRetry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deps := newConfirmDeps(t)
|
||||
key, err := shared.ParseClientPublicKey(publicKeyString())
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "ParseClientPublicKey() returned error: %v", err)
|
||||
}
|
||||
record := confirmedChallengeFixture(t, deps.hasher, "challenge-1", "pilot@example.com", "654321", "device-session-1", key, deps.now.Add(-time.Minute), deps.now.Add(time.Minute))
|
||||
if err := deps.challengeStore.Create(context.Background(), record); err != nil {
|
||||
require.Failf(t, "test failed", "Create() returned error: %v", err)
|
||||
}
|
||||
if err := deps.sessionStore.Create(context.Background(), activeSessionFixture("device-session-1", "user-1", key, deps.now.Add(-time.Minute))); err != nil {
|
||||
require.Failf(t, "test failed", "Create() returned error: %v", err)
|
||||
}
|
||||
|
||||
service := mustNewConfirmService(t, deps)
|
||||
_, err = service.Execute(context.Background(), Input{
|
||||
ChallengeID: "challenge-1",
|
||||
Code: "654321",
|
||||
ClientPublicKey: alternatePublicKeyString(),
|
||||
})
|
||||
if shared.CodeOf(err) != shared.ErrorCodeInvalidCode {
|
||||
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeInvalidCode)
|
||||
}
|
||||
|
||||
updated, err := deps.challengeStore.Get(context.Background(), common.ChallengeID("challenge-1"))
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "Get() returned error: %v", err)
|
||||
}
|
||||
if updated.Attempts.Confirm != 0 {
|
||||
require.Failf(t, "test failed", "Attempts.Confirm = %d, want 0", updated.Attempts.Confirm)
|
||||
}
|
||||
if updated.Confirmation == nil {
|
||||
require.FailNow(t, "Confirmation = nil, want metadata to stay intact")
|
||||
}
|
||||
if updated.Confirmation.SessionID != common.DeviceSessionID("device-session-1") {
|
||||
require.Failf(t, "test failed", "Confirmation.SessionID = %q, want %q", updated.Confirmation.SessionID, common.DeviceSessionID("device-session-1"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteReturnsInvalidCodeForNonConfirmableStates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
status challenge.Status
|
||||
deliveryState challenge.DeliveryState
|
||||
}{
|
||||
{name: "pending send", status: challenge.StatusPendingSend, deliveryState: challenge.DeliveryPending},
|
||||
{name: "failed", status: challenge.StatusFailed, deliveryState: challenge.DeliveryFailed},
|
||||
{name: "cancelled", status: challenge.StatusCancelled, deliveryState: challenge.DeliverySent},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deps := newConfirmDeps(t)
|
||||
record := sentChallengeFixture(t, deps.hasher, "challenge-1", "pilot@example.com", "654321", deps.now.Add(-time.Minute), deps.now.Add(time.Minute))
|
||||
record.Status = tt.status
|
||||
record.DeliveryState = tt.deliveryState
|
||||
if err := record.Validate(); err != nil {
|
||||
require.Failf(t, "test failed", "Validate() returned error: %v", err)
|
||||
}
|
||||
if err := deps.challengeStore.Create(context.Background(), record); err != nil {
|
||||
require.Failf(t, "test failed", "Create() returned error: %v", err)
|
||||
}
|
||||
|
||||
service := mustNewConfirmService(t, deps)
|
||||
_, err := service.Execute(context.Background(), Input{
|
||||
ChallengeID: "challenge-1",
|
||||
Code: "654321",
|
||||
ClientPublicKey: publicKeyString(),
|
||||
})
|
||||
if shared.CodeOf(err) != shared.ErrorCodeInvalidCode {
|
||||
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeInvalidCode)
|
||||
}
|
||||
|
||||
updated, err := deps.challengeStore.Get(context.Background(), common.ChallengeID("challenge-1"))
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "Get() returned error: %v", err)
|
||||
}
|
||||
if updated.Attempts.Confirm != 0 {
|
||||
require.Failf(t, "test failed", "Attempts.Confirm = %d, want 0", updated.Attempts.Confirm)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteMarksChallengeFailedAndReturnsBlockedByPolicy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deps := newConfirmDeps(t)
|
||||
if err := deps.userDirectory.SeedBlockedEmail(common.Email("pilot@example.com"), userresolution.BlockReasonCode("policy_block")); err != nil {
|
||||
require.Failf(t, "test failed", "SeedBlockedEmail() returned error: %v", err)
|
||||
}
|
||||
if err := deps.challengeStore.Create(context.Background(), sentChallengeFixture(t, deps.hasher, "challenge-1", "pilot@example.com", "654321", deps.now.Add(-time.Minute), deps.now.Add(time.Minute))); err != nil {
|
||||
require.Failf(t, "test failed", "Create() returned error: %v", err)
|
||||
}
|
||||
|
||||
service := mustNewConfirmService(t, deps)
|
||||
_, err := service.Execute(context.Background(), Input{
|
||||
ChallengeID: "challenge-1",
|
||||
Code: "654321",
|
||||
ClientPublicKey: publicKeyString(),
|
||||
})
|
||||
if shared.CodeOf(err) != shared.ErrorCodeBlockedByPolicy {
|
||||
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeBlockedByPolicy)
|
||||
}
|
||||
|
||||
record, err := deps.challengeStore.Get(context.Background(), common.ChallengeID("challenge-1"))
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "Get() returned error: %v", err)
|
||||
}
|
||||
if record.Status != challenge.StatusFailed {
|
||||
require.Failf(t, "test failed", "challenge status = %q, want %q", record.Status, challenge.StatusFailed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteReturnsSessionLimitExceededWithoutConsumingChallenge(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deps := newConfirmDeps(t)
|
||||
if err := deps.userDirectory.SeedExisting(common.Email("pilot@example.com"), common.UserID("user-1")); err != nil {
|
||||
require.Failf(t, "test failed", "SeedExisting() returned error: %v", err)
|
||||
}
|
||||
if err := deps.challengeStore.Create(context.Background(), sentChallengeFixture(t, deps.hasher, "challenge-1", "pilot@example.com", "654321", deps.now.Add(-time.Minute), deps.now.Add(time.Minute))); err != nil {
|
||||
require.Failf(t, "test failed", "Create() returned error: %v", err)
|
||||
}
|
||||
if err := deps.sessionStore.Create(context.Background(), activeSessionFixture("device-session-existing", "user-1", mustClientPublicKey(t, publicKeyString()), deps.now.Add(-2*time.Minute))); err != nil {
|
||||
require.Failf(t, "test failed", "Create() returned error: %v", err)
|
||||
}
|
||||
limit := 1
|
||||
deps.configProvider.Config.ActiveSessionLimit = &limit
|
||||
|
||||
service := mustNewConfirmService(t, deps)
|
||||
_, err := service.Execute(context.Background(), Input{
|
||||
ChallengeID: "challenge-1",
|
||||
Code: "654321",
|
||||
ClientPublicKey: publicKeyString(),
|
||||
})
|
||||
if shared.CodeOf(err) != shared.ErrorCodeSessionLimitExceeded {
|
||||
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeSessionLimitExceeded)
|
||||
}
|
||||
|
||||
record, err := deps.challengeStore.Get(context.Background(), common.ChallengeID("challenge-1"))
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "Get() returned error: %v", err)
|
||||
}
|
||||
if record.Status != challenge.StatusSent {
|
||||
require.Failf(t, "test failed", "challenge status = %q, want %q", record.Status, challenge.StatusSent)
|
||||
}
|
||||
if record.Attempts.Confirm != 0 {
|
||||
require.Failf(t, "test failed", "Attempts.Confirm = %d, want 0", record.Attempts.Confirm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteReturnsServiceUnavailableThenSucceedsIdempotentlyAfterPublishFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deps := newConfirmDeps(t)
|
||||
deps.publisher.Err = errors.New("publish failed")
|
||||
if err := deps.userDirectory.SeedExisting(common.Email("pilot@example.com"), common.UserID("user-1")); err != nil {
|
||||
require.Failf(t, "test failed", "SeedExisting() returned error: %v", err)
|
||||
}
|
||||
if err := deps.challengeStore.Create(context.Background(), sentChallengeFixture(t, deps.hasher, "challenge-1", "pilot@example.com", "654321", deps.now.Add(-time.Minute), deps.now.Add(time.Minute))); err != nil {
|
||||
require.Failf(t, "test failed", "Create() returned error: %v", err)
|
||||
}
|
||||
|
||||
service := mustNewConfirmService(t, deps)
|
||||
_, err := service.Execute(context.Background(), Input{
|
||||
ChallengeID: "challenge-1",
|
||||
Code: "654321",
|
||||
ClientPublicKey: publicKeyString(),
|
||||
})
|
||||
if shared.CodeOf(err) != shared.ErrorCodeServiceUnavailable {
|
||||
require.Failf(t, "test failed", "first Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeServiceUnavailable)
|
||||
}
|
||||
|
||||
deps.publisher.Err = nil
|
||||
result, err := service.Execute(context.Background(), Input{
|
||||
ChallengeID: "challenge-1",
|
||||
Code: "654321",
|
||||
ClientPublicKey: publicKeyString(),
|
||||
})
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "second Execute() returned error: %v", err)
|
||||
}
|
||||
if result.DeviceSessionID != "device-session-1" {
|
||||
require.Failf(t, "test failed", "second Execute().DeviceSessionID = %q, want %q", result.DeviceSessionID, "device-session-1")
|
||||
}
|
||||
}
|
||||
|
||||
type confirmDeps struct {
|
||||
challengeStore *testkit.InMemoryChallengeStore
|
||||
sessionStore *testkit.InMemorySessionStore
|
||||
userDirectory *testkit.InMemoryUserDirectory
|
||||
configProvider testkit.StaticConfigProvider
|
||||
publisher *testkit.RecordingProjectionPublisher
|
||||
idGenerator *testkit.SequenceIDGenerator
|
||||
hasher testkit.DeterministicCodeHasher
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func newConfirmDeps(t *testing.T) confirmDeps {
|
||||
t.Helper()
|
||||
|
||||
return confirmDeps{
|
||||
challengeStore: &testkit.InMemoryChallengeStore{},
|
||||
sessionStore: &testkit.InMemorySessionStore{},
|
||||
userDirectory: &testkit.InMemoryUserDirectory{},
|
||||
configProvider: testkit.StaticConfigProvider{},
|
||||
publisher: &testkit.RecordingProjectionPublisher{},
|
||||
idGenerator: &testkit.SequenceIDGenerator{
|
||||
DeviceSessionIDs: []common.DeviceSessionID{"device-session-1"},
|
||||
},
|
||||
hasher: testkit.DeterministicCodeHasher{},
|
||||
now: time.Unix(20, 0).UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func mustNewConfirmService(t *testing.T, deps confirmDeps) *Service {
|
||||
t.Helper()
|
||||
|
||||
service, err := New(
|
||||
deps.challengeStore,
|
||||
deps.sessionStore,
|
||||
deps.userDirectory,
|
||||
deps.configProvider,
|
||||
deps.publisher,
|
||||
deps.idGenerator,
|
||||
deps.hasher,
|
||||
testkit.FixedClock{Time: deps.now},
|
||||
)
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "New() returned error: %v", err)
|
||||
}
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
func sentChallengeFixture(
|
||||
t *testing.T,
|
||||
hasher testkit.DeterministicCodeHasher,
|
||||
challengeID string,
|
||||
email string,
|
||||
code string,
|
||||
createdAt time.Time,
|
||||
expiresAt time.Time,
|
||||
) challenge.Challenge {
|
||||
t.Helper()
|
||||
|
||||
codeHash, err := hasher.Hash(code)
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "Hash() returned error: %v", err)
|
||||
}
|
||||
|
||||
record := challenge.Challenge{
|
||||
ID: common.ChallengeID(challengeID),
|
||||
Email: common.Email(email),
|
||||
CodeHash: codeHash,
|
||||
Status: challenge.StatusSent,
|
||||
DeliveryState: challenge.DeliverySent,
|
||||
CreatedAt: createdAt,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
require.Failf(t, "test failed", "Validate() returned error: %v", err)
|
||||
}
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
func confirmedChallengeFixture(
|
||||
t *testing.T,
|
||||
hasher testkit.DeterministicCodeHasher,
|
||||
challengeID string,
|
||||
email string,
|
||||
code string,
|
||||
deviceSessionID string,
|
||||
clientPublicKey common.ClientPublicKey,
|
||||
createdAt time.Time,
|
||||
expiresAt time.Time,
|
||||
) challenge.Challenge {
|
||||
t.Helper()
|
||||
|
||||
record := sentChallengeFixture(t, hasher, challengeID, email, code, createdAt, expiresAt)
|
||||
record.Status = challenge.StatusConfirmedPendingExpire
|
||||
record.Confirmation = &challenge.Confirmation{
|
||||
SessionID: common.DeviceSessionID(deviceSessionID),
|
||||
ClientPublicKey: clientPublicKey,
|
||||
ConfirmedAt: createdAt.Add(time.Minute),
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
require.Failf(t, "test failed", "Validate() returned error: %v", err)
|
||||
}
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
func activeSessionFixture(deviceSessionID string, userID string, clientPublicKey common.ClientPublicKey, createdAt time.Time) devicesession.Session {
|
||||
return devicesession.Session{
|
||||
ID: common.DeviceSessionID(deviceSessionID),
|
||||
UserID: common.UserID(userID),
|
||||
ClientPublicKey: clientPublicKey,
|
||||
Status: devicesession.StatusActive,
|
||||
CreatedAt: createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
func mustClientPublicKey(t *testing.T, value string) common.ClientPublicKey {
|
||||
t.Helper()
|
||||
|
||||
key, err := shared.ParseClientPublicKey(value)
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "ParseClientPublicKey() returned error: %v", err)
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
func publicKeyString() string {
|
||||
return "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
}
|
||||
|
||||
func alternatePublicKeyString() string {
|
||||
return "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package confirmemailcode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stubuserservice "galaxy/authsession/internal/adapters/userservice"
|
||||
"galaxy/authsession/internal/domain/challenge"
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
"galaxy/authsession/internal/domain/userresolution"
|
||||
"galaxy/authsession/internal/service/shared"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestExecuteWithRuntimeStubUserDirectory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("creates user through EnsureUserByEmail", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deps := newConfirmDeps(t)
|
||||
userDirectory := &stubuserservice.StubDirectory{}
|
||||
require.NoError(t, userDirectory.QueueCreatedUserIDs(common.UserID("user-created")))
|
||||
deps.userDirectory = nil
|
||||
require.NoError(t, deps.challengeStore.Create(context.Background(), sentChallengeFixture(
|
||||
t,
|
||||
deps.hasher,
|
||||
"challenge-1",
|
||||
"pilot@example.com",
|
||||
"654321",
|
||||
deps.now.Add(-time.Minute),
|
||||
deps.now.Add(time.Minute),
|
||||
)))
|
||||
|
||||
service, err := New(
|
||||
deps.challengeStore,
|
||||
deps.sessionStore,
|
||||
userDirectory,
|
||||
deps.configProvider,
|
||||
deps.publisher,
|
||||
deps.idGenerator,
|
||||
deps.hasher,
|
||||
fixedClock(deps.now),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), Input{
|
||||
ChallengeID: "challenge-1",
|
||||
Code: "654321",
|
||||
ClientPublicKey: publicKeyString(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "device-session-1", result.DeviceSessionID)
|
||||
|
||||
sessionRecord, err := deps.sessionStore.Get(context.Background(), common.DeviceSessionID("device-session-1"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, common.UserID("user-created"), sessionRecord.UserID)
|
||||
})
|
||||
|
||||
t.Run("blocked email returns blocked by policy", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deps := newConfirmDeps(t)
|
||||
userDirectory := &stubuserservice.StubDirectory{}
|
||||
require.NoError(t, userDirectory.SeedBlockedEmail(common.Email("pilot@example.com"), userresolution.BlockReasonCode("policy_block")))
|
||||
require.NoError(t, deps.challengeStore.Create(context.Background(), sentChallengeFixture(
|
||||
t,
|
||||
deps.hasher,
|
||||
"challenge-1",
|
||||
"pilot@example.com",
|
||||
"654321",
|
||||
deps.now.Add(-time.Minute),
|
||||
deps.now.Add(time.Minute),
|
||||
)))
|
||||
|
||||
service, err := New(
|
||||
deps.challengeStore,
|
||||
deps.sessionStore,
|
||||
userDirectory,
|
||||
deps.configProvider,
|
||||
deps.publisher,
|
||||
deps.idGenerator,
|
||||
deps.hasher,
|
||||
fixedClock(deps.now),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), Input{
|
||||
ChallengeID: "challenge-1",
|
||||
Code: "654321",
|
||||
ClientPublicKey: publicKeyString(),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, shared.ErrorCodeBlockedByPolicy, shared.CodeOf(err))
|
||||
|
||||
record, getErr := deps.challengeStore.Get(context.Background(), common.ChallengeID("challenge-1"))
|
||||
require.NoError(t, getErr)
|
||||
assert.Equal(t, challenge.StatusFailed, record.Status)
|
||||
})
|
||||
}
|
||||
|
||||
type fixedClock time.Time
|
||||
|
||||
func (c fixedClock) Now() time.Time {
|
||||
return time.Time(c)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package confirmemailcode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/authsession/internal/domain/challenge"
|
||||
authtelemetry "galaxy/authsession/internal/telemetry"
|
||||
"galaxy/authsession/internal/testkit"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
||||
)
|
||||
|
||||
func TestExecuteRecordsInvalidCodeMetricForThrottledChallenge(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, reader := newObservedConfirmTelemetryRuntime(t)
|
||||
deps := newConfirmDeps(t)
|
||||
record := sentChallengeFixture(t, deps.hasher, "challenge-1", "pilot@example.com", "654321", deps.now.Add(-time.Minute), deps.now.Add(time.Minute))
|
||||
record.Status = challenge.StatusDeliveryThrottled
|
||||
record.DeliveryState = challenge.DeliveryThrottled
|
||||
require.NoError(t, record.Validate())
|
||||
require.NoError(t, deps.challengeStore.Create(context.Background(), record))
|
||||
|
||||
service, err := NewWithTelemetry(
|
||||
deps.challengeStore,
|
||||
deps.sessionStore,
|
||||
deps.userDirectory,
|
||||
deps.configProvider,
|
||||
deps.publisher,
|
||||
deps.idGenerator,
|
||||
deps.hasher,
|
||||
testkit.FixedClock{Time: deps.now},
|
||||
runtime,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), Input{
|
||||
ChallengeID: "challenge-1",
|
||||
Code: "654321",
|
||||
ClientPublicKey: publicKeyString(),
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
assertConfirmMetricCount(t, reader, map[string]string{"outcome": "invalid_code"}, 1)
|
||||
}
|
||||
|
||||
func newObservedConfirmTelemetryRuntime(t *testing.T) (*authtelemetry.Runtime, *sdkmetric.ManualReader) {
|
||||
t.Helper()
|
||||
|
||||
reader := sdkmetric.NewManualReader()
|
||||
provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
|
||||
|
||||
runtime, err := authtelemetry.New(provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
return runtime, reader
|
||||
}
|
||||
|
||||
func assertConfirmMetricCount(t *testing.T, reader *sdkmetric.ManualReader, wantAttrs map[string]string, wantValue int64) {
|
||||
t.Helper()
|
||||
|
||||
var resourceMetrics metricdata.ResourceMetrics
|
||||
require.NoError(t, reader.Collect(context.Background(), &resourceMetrics))
|
||||
|
||||
for _, scopeMetrics := range resourceMetrics.ScopeMetrics {
|
||||
for _, metric := range scopeMetrics.Metrics {
|
||||
if metric.Name != "authsession.confirm_email_code.attempts" {
|
||||
continue
|
||||
}
|
||||
|
||||
sum, ok := metric.Data.(metricdata.Sum[int64])
|
||||
require.True(t, ok)
|
||||
|
||||
for _, point := range sum.DataPoints {
|
||||
if hasConfirmMetricAttributes(point.Attributes.ToSlice(), wantAttrs) {
|
||||
assert.Equal(t, wantValue, point.Value)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
require.Failf(t, "test failed", "confirm metric with attrs %v not found", wantAttrs)
|
||||
}
|
||||
|
||||
func hasConfirmMetricAttributes(values []attribute.KeyValue, want map[string]string) bool {
|
||||
if len(values) != len(want) {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, value := range values {
|
||||
if want[string(value.Key)] != value.Value.AsString() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user