feat: authsession service
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
package sendemailcode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/authsession/internal/domain/challenge"
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
"galaxy/authsession/internal/domain/userresolution"
|
||||
"galaxy/authsession/internal/ports"
|
||||
"galaxy/authsession/internal/testkit"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestExecuteCreatesThrottledChallengeWithoutUserDirectoryOrMail(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
challengeStore := &testkit.InMemoryChallengeStore{}
|
||||
abuseProtector := &testkit.InMemorySendEmailCodeAbuseProtector{}
|
||||
now := time.Unix(10, 0).UTC()
|
||||
require.NoError(t, reserveSendCooldown(abuseProtector, common.Email("pilot@example.com"), now))
|
||||
|
||||
userDirectory := &countingUserDirectory{}
|
||||
mailSender := &testkit.RecordingMailSender{}
|
||||
service, err := NewWithRuntime(
|
||||
challengeStore,
|
||||
userDirectory,
|
||||
&testkit.SequenceIDGenerator{ChallengeIDs: []common.ChallengeID{"challenge-1"}},
|
||||
testkit.FixedCodeGenerator{Code: "654321"},
|
||||
testkit.DeterministicCodeHasher{},
|
||||
mailSender,
|
||||
abuseProtector,
|
||||
testkit.FixedClock{Time: now},
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), Input{Email: "pilot@example.com"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "challenge-1", result.ChallengeID)
|
||||
assert.Zero(t, userDirectory.resolveCalls)
|
||||
assert.Empty(t, mailSender.RecordedInputs())
|
||||
|
||||
record, getErr := challengeStore.Get(context.Background(), common.ChallengeID("challenge-1"))
|
||||
require.NoError(t, getErr)
|
||||
assert.Equal(t, challenge.StatusDeliveryThrottled, record.Status)
|
||||
assert.Equal(t, challenge.DeliveryThrottled, record.DeliveryState)
|
||||
assert.Equal(t, 1, record.Attempts.Send)
|
||||
}
|
||||
|
||||
func TestExecuteBlockedEmailOutsideThrottleStillSuppressesDelivery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
challengeStore := &testkit.InMemoryChallengeStore{}
|
||||
userDirectory := &testkit.InMemoryUserDirectory{}
|
||||
require.NoError(t, userDirectory.SeedBlockedEmail(common.Email("pilot@example.com"), userresolution.BlockReasonCode("policy_block")))
|
||||
mailSender := &testkit.RecordingMailSender{}
|
||||
|
||||
service, err := NewWithRuntime(
|
||||
challengeStore,
|
||||
userDirectory,
|
||||
&testkit.SequenceIDGenerator{ChallengeIDs: []common.ChallengeID{"challenge-1"}},
|
||||
testkit.FixedCodeGenerator{Code: "654321"},
|
||||
testkit.DeterministicCodeHasher{},
|
||||
mailSender,
|
||||
&testkit.InMemorySendEmailCodeAbuseProtector{},
|
||||
testkit.FixedClock{Time: time.Unix(10, 0).UTC()},
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), Input{Email: "pilot@example.com"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "challenge-1", result.ChallengeID)
|
||||
assert.Empty(t, mailSender.RecordedInputs())
|
||||
|
||||
record, getErr := challengeStore.Get(context.Background(), common.ChallengeID("challenge-1"))
|
||||
require.NoError(t, getErr)
|
||||
assert.Equal(t, challenge.StatusDeliverySuppressed, record.Status)
|
||||
assert.Equal(t, challenge.DeliverySuppressed, record.DeliveryState)
|
||||
}
|
||||
|
||||
func TestExecuteAllowsAgainAfterCooldown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
challengeStore := &testkit.InMemoryChallengeStore{}
|
||||
userDirectory := &testkit.InMemoryUserDirectory{}
|
||||
mailSender := &testkit.RecordingMailSender{}
|
||||
abuseProtector := &testkit.InMemorySendEmailCodeAbuseProtector{}
|
||||
clock := &mutableClock{time: time.Unix(10, 0).UTC()}
|
||||
idGenerator := &testkit.SequenceIDGenerator{
|
||||
ChallengeIDs: []common.ChallengeID{"challenge-1", "challenge-2"},
|
||||
}
|
||||
|
||||
service, err := NewWithRuntime(
|
||||
challengeStore,
|
||||
userDirectory,
|
||||
idGenerator,
|
||||
testkit.FixedCodeGenerator{Code: "654321"},
|
||||
testkit.DeterministicCodeHasher{},
|
||||
mailSender,
|
||||
abuseProtector,
|
||||
clock,
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
first, err := service.Execute(context.Background(), Input{Email: "pilot@example.com"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "challenge-1", first.ChallengeID)
|
||||
|
||||
clock.time = clock.time.Add(challenge.ResendThrottleCooldown)
|
||||
|
||||
second, err := service.Execute(context.Background(), Input{Email: "pilot@example.com"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "challenge-2", second.ChallengeID)
|
||||
require.Len(t, mailSender.RecordedInputs(), 2)
|
||||
|
||||
secondRecord, getErr := challengeStore.Get(context.Background(), common.ChallengeID("challenge-2"))
|
||||
require.NoError(t, getErr)
|
||||
assert.Equal(t, challenge.StatusSent, secondRecord.Status)
|
||||
assert.Equal(t, challenge.DeliverySent, secondRecord.DeliveryState)
|
||||
}
|
||||
|
||||
func reserveSendCooldown(protector ports.SendEmailCodeAbuseProtector, email common.Email, now time.Time) error {
|
||||
_, err := protector.CheckAndReserve(context.Background(), ports.SendEmailCodeAbuseInput{
|
||||
Email: email,
|
||||
Now: now,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
type mutableClock struct {
|
||||
time time.Time
|
||||
}
|
||||
|
||||
func (c *mutableClock) Now() time.Time {
|
||||
return c.time
|
||||
}
|
||||
|
||||
type countingUserDirectory struct {
|
||||
resolveCalls int
|
||||
}
|
||||
|
||||
func (d *countingUserDirectory) ResolveByEmail(_ context.Context, _ common.Email) (userresolution.Result, error) {
|
||||
d.resolveCalls++
|
||||
return userresolution.Result{Kind: userresolution.KindCreatable}, nil
|
||||
}
|
||||
|
||||
func (d *countingUserDirectory) ExistsByUserID(context.Context, common.UserID) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (d *countingUserDirectory) EnsureUserByEmail(context.Context, common.Email) (ports.EnsureUserResult, error) {
|
||||
return ports.EnsureUserResult{}, nil
|
||||
}
|
||||
|
||||
func (d *countingUserDirectory) BlockByUserID(context.Context, ports.BlockUserByIDInput) (ports.BlockUserResult, error) {
|
||||
return ports.BlockUserResult{}, nil
|
||||
}
|
||||
|
||||
func (d *countingUserDirectory) BlockByEmail(context.Context, ports.BlockUserByEmailInput) (ports.BlockUserResult, error) {
|
||||
return ports.BlockUserResult{}, nil
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package sendemailcode
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
"galaxy/authsession/internal/testkit"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
func TestExecuteLogsSafeOutcomeFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger, buffer := newObservedServiceLogger()
|
||||
service, err := NewWithObservability(
|
||||
&testkit.InMemoryChallengeStore{},
|
||||
&testkit.InMemoryUserDirectory{},
|
||||
&testkit.SequenceIDGenerator{ChallengeIDs: []common.ChallengeID{"challenge-1"}},
|
||||
testkit.FixedCodeGenerator{Code: "654321"},
|
||||
testkit.DeterministicCodeHasher{},
|
||||
&testkit.RecordingMailSender{},
|
||||
nil,
|
||||
testkit.FixedClock{Time: time.Unix(10, 0).UTC()},
|
||||
logger,
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), Input{Email: "pilot@example.com"})
|
||||
require.NoError(t, err)
|
||||
|
||||
logOutput := buffer.String()
|
||||
assert.Contains(t, logOutput, "send_email_code")
|
||||
assert.Contains(t, logOutput, "challenge-1")
|
||||
assert.Contains(t, logOutput, "\"outcome\":\"sent\"")
|
||||
assert.NotContains(t, logOutput, "pilot@example.com")
|
||||
assert.NotContains(t, logOutput, "654321")
|
||||
}
|
||||
|
||||
func newObservedServiceLogger() (*zap.Logger, *bytes.Buffer) {
|
||||
buffer := &bytes.Buffer{}
|
||||
encoderConfig := zap.NewProductionEncoderConfig()
|
||||
encoderConfig.TimeKey = ""
|
||||
|
||||
core := zapcore.NewCore(
|
||||
zapcore.NewJSONEncoder(encoderConfig),
|
||||
zapcore.AddSync(buffer),
|
||||
zap.DebugLevel,
|
||||
)
|
||||
|
||||
return zap.New(core), buffer
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
// Package sendemailcode implements the public send-email-code use case.
|
||||
package sendemailcode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"galaxy/authsession/internal/domain/challenge"
|
||||
"galaxy/authsession/internal/domain/userresolution"
|
||||
"galaxy/authsession/internal/ports"
|
||||
"galaxy/authsession/internal/service/shared"
|
||||
"galaxy/authsession/internal/telemetry"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Input describes one public send-email-code request.
|
||||
type Input struct {
|
||||
// Email is the user-supplied e-mail address that should receive the login
|
||||
// code.
|
||||
Email string
|
||||
}
|
||||
|
||||
// Result describes one public send-email-code response.
|
||||
type Result struct {
|
||||
// ChallengeID is the stable challenge identifier returned to the caller.
|
||||
ChallengeID string
|
||||
}
|
||||
|
||||
// Service executes the public send-email-code use case.
|
||||
type Service struct {
|
||||
challengeStore ports.ChallengeStore
|
||||
userDirectory ports.UserDirectory
|
||||
idGenerator ports.IDGenerator
|
||||
codeGenerator ports.CodeGenerator
|
||||
codeHasher ports.CodeHasher
|
||||
mailSender ports.MailSender
|
||||
abuseProtector ports.SendEmailCodeAbuseProtector
|
||||
clock ports.Clock
|
||||
logger *zap.Logger
|
||||
telemetry *telemetry.Runtime
|
||||
}
|
||||
|
||||
// New returns a send-email-code service wired to the required ports.
|
||||
func New(
|
||||
challengeStore ports.ChallengeStore,
|
||||
userDirectory ports.UserDirectory,
|
||||
idGenerator ports.IDGenerator,
|
||||
codeGenerator ports.CodeGenerator,
|
||||
codeHasher ports.CodeHasher,
|
||||
mailSender ports.MailSender,
|
||||
clock ports.Clock,
|
||||
) (*Service, error) {
|
||||
return NewWithRuntime(
|
||||
challengeStore,
|
||||
userDirectory,
|
||||
idGenerator,
|
||||
codeGenerator,
|
||||
codeHasher,
|
||||
mailSender,
|
||||
nil,
|
||||
clock,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
// NewWithRuntime returns a send-email-code service wired to the required
|
||||
// ports plus the optional Stage-17 runtime collaborators.
|
||||
func NewWithRuntime(
|
||||
challengeStore ports.ChallengeStore,
|
||||
userDirectory ports.UserDirectory,
|
||||
idGenerator ports.IDGenerator,
|
||||
codeGenerator ports.CodeGenerator,
|
||||
codeHasher ports.CodeHasher,
|
||||
mailSender ports.MailSender,
|
||||
abuseProtector ports.SendEmailCodeAbuseProtector,
|
||||
clock ports.Clock,
|
||||
telemetryRuntime *telemetry.Runtime,
|
||||
) (*Service, error) {
|
||||
return NewWithObservability(
|
||||
challengeStore,
|
||||
userDirectory,
|
||||
idGenerator,
|
||||
codeGenerator,
|
||||
codeHasher,
|
||||
mailSender,
|
||||
abuseProtector,
|
||||
clock,
|
||||
nil,
|
||||
telemetryRuntime,
|
||||
)
|
||||
}
|
||||
|
||||
// NewWithObservability returns a send-email-code service wired to the required
|
||||
// ports plus optional structured logging and telemetry dependencies.
|
||||
func NewWithObservability(
|
||||
challengeStore ports.ChallengeStore,
|
||||
userDirectory ports.UserDirectory,
|
||||
idGenerator ports.IDGenerator,
|
||||
codeGenerator ports.CodeGenerator,
|
||||
codeHasher ports.CodeHasher,
|
||||
mailSender ports.MailSender,
|
||||
abuseProtector ports.SendEmailCodeAbuseProtector,
|
||||
clock ports.Clock,
|
||||
logger *zap.Logger,
|
||||
telemetryRuntime *telemetry.Runtime,
|
||||
) (*Service, error) {
|
||||
switch {
|
||||
case challengeStore == nil:
|
||||
return nil, fmt.Errorf("sendemailcode: challenge store must not be nil")
|
||||
case userDirectory == nil:
|
||||
return nil, fmt.Errorf("sendemailcode: user directory must not be nil")
|
||||
case idGenerator == nil:
|
||||
return nil, fmt.Errorf("sendemailcode: id generator must not be nil")
|
||||
case codeGenerator == nil:
|
||||
return nil, fmt.Errorf("sendemailcode: code generator must not be nil")
|
||||
case codeHasher == nil:
|
||||
return nil, fmt.Errorf("sendemailcode: code hasher must not be nil")
|
||||
case mailSender == nil:
|
||||
return nil, fmt.Errorf("sendemailcode: mail sender must not be nil")
|
||||
case clock == nil:
|
||||
return nil, fmt.Errorf("sendemailcode: clock must not be nil")
|
||||
default:
|
||||
return &Service{
|
||||
challengeStore: challengeStore,
|
||||
userDirectory: userDirectory,
|
||||
idGenerator: idGenerator,
|
||||
codeGenerator: codeGenerator,
|
||||
codeHasher: codeHasher,
|
||||
mailSender: mailSender,
|
||||
abuseProtector: normalizeAbuseProtector(abuseProtector),
|
||||
clock: clock,
|
||||
logger: namedLogger(logger, "send_email_code"),
|
||||
telemetry: telemetryRuntime,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Execute creates a fresh challenge for every request, stores only the hashed
|
||||
// confirmation code, and records whether delivery was sent or intentionally
|
||||
// suppressed.
|
||||
func (s *Service) Execute(ctx context.Context, input Input) (result Result, err error) {
|
||||
logFields := []zap.Field{
|
||||
zap.String("component", "service"),
|
||||
zap.String("use_case", "send_email_code"),
|
||||
}
|
||||
outcome := ""
|
||||
defer func() {
|
||||
if outcome != "" {
|
||||
logFields = append(logFields, zap.String("outcome", outcome))
|
||||
}
|
||||
if result.ChallengeID != "" {
|
||||
logFields = append(logFields, zap.String("challenge_id", result.ChallengeID))
|
||||
}
|
||||
shared.LogServiceOutcome(s.logger, ctx, "send email code completed", err, logFields...)
|
||||
}()
|
||||
|
||||
email, err := shared.ParseEmail(input.Email)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
now := s.clock.Now().UTC()
|
||||
abuseResult, err := s.abuseProtector.CheckAndReserve(ctx, ports.SendEmailCodeAbuseInput{
|
||||
Email: email,
|
||||
Now: now,
|
||||
})
|
||||
if err != nil {
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
if err := abuseResult.Validate(); err != nil {
|
||||
return Result{}, shared.InternalError(err)
|
||||
}
|
||||
|
||||
challengeID, err := s.idGenerator.NewChallengeID()
|
||||
if err != nil {
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
code, err := s.codeGenerator.Generate()
|
||||
if err != nil {
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
codeHash, err := s.codeHasher.Hash(code)
|
||||
if err != nil {
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
|
||||
pendingStatus, pendingDeliveryState, err := ports.SendEmailCodeThrottleStatusToChallengeStatus(abuseResult.Outcome)
|
||||
if err != nil {
|
||||
return Result{}, shared.InternalError(err)
|
||||
}
|
||||
pending := challenge.Challenge{
|
||||
ID: challengeID,
|
||||
Email: email,
|
||||
CodeHash: codeHash,
|
||||
Status: pendingStatus,
|
||||
DeliveryState: pendingDeliveryState,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(challenge.InitialTTL),
|
||||
}
|
||||
if err := pending.Validate(); err != nil {
|
||||
return Result{}, shared.InternalError(err)
|
||||
}
|
||||
if err := s.challengeStore.Create(ctx, pending); err != nil {
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
s.telemetry.RecordChallengeCreated(ctx)
|
||||
|
||||
final := pending
|
||||
final.Attempts.Send = 1
|
||||
final.Abuse.LastAttemptAt = &now
|
||||
if abuseResult.Outcome == ports.SendEmailCodeAbuseOutcomeThrottled {
|
||||
result, err = s.finishChallenge(ctx, pending, final)
|
||||
if err == nil {
|
||||
outcome = string(telemetry.SendEmailCodeOutcomeThrottled)
|
||||
s.telemetry.RecordSendEmailCode(ctx, telemetry.SendEmailCodeOutcomeThrottled, telemetry.SendEmailCodeReasonThrottled)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
resolution, err := s.userDirectory.ResolveByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
if err := resolution.Validate(); err != nil {
|
||||
return Result{}, shared.InternalError(err)
|
||||
}
|
||||
s.telemetry.RecordUserDirectoryOutcome(ctx, "resolve_by_email", string(resolution.Kind))
|
||||
|
||||
switch resolution.Kind {
|
||||
case userresolution.KindBlocked:
|
||||
final.Status = challenge.StatusDeliverySuppressed
|
||||
final.DeliveryState = challenge.DeliverySuppressed
|
||||
result, err = s.finishChallenge(ctx, pending, final)
|
||||
if err == nil {
|
||||
outcome = string(telemetry.SendEmailCodeOutcomeSuppressed)
|
||||
s.telemetry.RecordSendEmailCode(ctx, telemetry.SendEmailCodeOutcomeSuppressed, telemetry.SendEmailCodeReasonBlocked)
|
||||
}
|
||||
return result, err
|
||||
default:
|
||||
deliveryResult, err := s.mailSender.SendLoginCode(ctx, ports.SendLoginCodeInput{
|
||||
Email: email,
|
||||
Code: code,
|
||||
})
|
||||
if err != nil {
|
||||
final.Status = challenge.StatusFailed
|
||||
final.DeliveryState = challenge.DeliveryFailed
|
||||
if _, persistErr := s.finishChallenge(ctx, pending, final); persistErr != nil {
|
||||
return Result{}, persistErr
|
||||
}
|
||||
outcome = string(telemetry.SendEmailCodeOutcomeFailed)
|
||||
s.telemetry.RecordSendEmailCode(ctx, telemetry.SendEmailCodeOutcomeFailed, telemetry.SendEmailCodeReasonMailSender)
|
||||
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
if err := deliveryResult.Validate(); err != nil {
|
||||
return Result{}, shared.InternalError(err)
|
||||
}
|
||||
|
||||
switch deliveryResult.Outcome {
|
||||
case ports.SendLoginCodeOutcomeSent:
|
||||
final.Status = challenge.StatusSent
|
||||
final.DeliveryState = challenge.DeliverySent
|
||||
result, err = s.finishChallenge(ctx, pending, final)
|
||||
if err == nil {
|
||||
outcome = string(telemetry.SendEmailCodeOutcomeSent)
|
||||
s.telemetry.RecordSendEmailCode(ctx, telemetry.SendEmailCodeOutcomeSent, "")
|
||||
}
|
||||
return result, err
|
||||
case ports.SendLoginCodeOutcomeSuppressed:
|
||||
final.Status = challenge.StatusDeliverySuppressed
|
||||
final.DeliveryState = challenge.DeliverySuppressed
|
||||
result, err = s.finishChallenge(ctx, pending, final)
|
||||
if err == nil {
|
||||
outcome = string(telemetry.SendEmailCodeOutcomeSuppressed)
|
||||
s.telemetry.RecordSendEmailCode(ctx, telemetry.SendEmailCodeOutcomeSuppressed, telemetry.SendEmailCodeReasonMailSender)
|
||||
}
|
||||
return result, err
|
||||
default:
|
||||
return Result{}, shared.InternalError(fmt.Errorf("sendemailcode: unsupported delivery outcome %q", deliveryResult.Outcome))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) finishChallenge(ctx context.Context, pending challenge.Challenge, final challenge.Challenge) (Result, error) {
|
||||
if err := final.Validate(); err != nil {
|
||||
return Result{}, shared.InternalError(err)
|
||||
}
|
||||
if err := s.challengeStore.CompareAndSwap(ctx, pending, final); err != nil {
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
|
||||
return Result{ChallengeID: final.ID.String()}, nil
|
||||
}
|
||||
|
||||
func normalizeAbuseProtector(protector ports.SendEmailCodeAbuseProtector) ports.SendEmailCodeAbuseProtector {
|
||||
if protector == nil {
|
||||
return allowAllSendEmailCodeAbuseProtector{}
|
||||
}
|
||||
|
||||
value := reflect.ValueOf(protector)
|
||||
switch value.Kind() {
|
||||
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
||||
if value.IsNil() {
|
||||
return allowAllSendEmailCodeAbuseProtector{}
|
||||
}
|
||||
}
|
||||
|
||||
return protector
|
||||
}
|
||||
|
||||
type allowAllSendEmailCodeAbuseProtector struct{}
|
||||
|
||||
func (allowAllSendEmailCodeAbuseProtector) CheckAndReserve(_ context.Context, input ports.SendEmailCodeAbuseInput) (ports.SendEmailCodeAbuseResult, error) {
|
||||
if err := input.Validate(); err != nil {
|
||||
return ports.SendEmailCodeAbuseResult{}, err
|
||||
}
|
||||
|
||||
return ports.SendEmailCodeAbuseResult{
|
||||
Outcome: ports.SendEmailCodeAbuseOutcomeAllowed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func namedLogger(logger *zap.Logger, name string) *zap.Logger {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
|
||||
return logger.Named(name)
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package sendemailcode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/authsession/internal/domain/challenge"
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
"galaxy/authsession/internal/domain/userresolution"
|
||||
"galaxy/authsession/internal/ports"
|
||||
"galaxy/authsession/internal/service/shared"
|
||||
"galaxy/authsession/internal/testkit"
|
||||
)
|
||||
|
||||
func TestExecuteSendsChallengeForExistingAndCreatableUsers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
seed func(*testkit.InMemoryUserDirectory) error
|
||||
email string
|
||||
}{
|
||||
{
|
||||
name: "existing",
|
||||
seed: func(directory *testkit.InMemoryUserDirectory) error {
|
||||
return directory.SeedExisting(common.Email("pilot@example.com"), common.UserID("user-1"))
|
||||
},
|
||||
email: " pilot@example.com ",
|
||||
},
|
||||
{
|
||||
name: "creatable",
|
||||
seed: func(*testkit.InMemoryUserDirectory) error { return nil },
|
||||
email: "new@example.com",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
challengeStore := &testkit.InMemoryChallengeStore{}
|
||||
userDirectory := &testkit.InMemoryUserDirectory{}
|
||||
if err := tt.seed(userDirectory); err != nil {
|
||||
require.Failf(t, "test failed", "seed() returned error: %v", err)
|
||||
}
|
||||
mailSender := &testkit.RecordingMailSender{}
|
||||
service, err := New(
|
||||
challengeStore,
|
||||
userDirectory,
|
||||
&testkit.SequenceIDGenerator{ChallengeIDs: []common.ChallengeID{"challenge-1"}},
|
||||
testkit.FixedCodeGenerator{Code: "654321"},
|
||||
testkit.DeterministicCodeHasher{},
|
||||
mailSender,
|
||||
testkit.FixedClock{Time: time.Unix(10, 0).UTC()},
|
||||
)
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "New() returned error: %v", err)
|
||||
}
|
||||
|
||||
result, err := service.Execute(context.Background(), Input{Email: tt.email})
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "Execute() returned error: %v", err)
|
||||
}
|
||||
if result.ChallengeID != "challenge-1" {
|
||||
require.Failf(t, "test failed", "Execute().ChallengeID = %q, want %q", result.ChallengeID, "challenge-1")
|
||||
}
|
||||
if len(mailSender.RecordedInputs()) != 1 {
|
||||
require.Failf(t, "test failed", "RecordedInputs() length = %d, want 1", len(mailSender.RecordedInputs()))
|
||||
}
|
||||
|
||||
record, err := 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 || record.DeliveryState != challenge.DeliverySent {
|
||||
require.Failf(t, "test failed", "challenge state = %q/%q", record.Status, record.DeliveryState)
|
||||
}
|
||||
if record.Attempts.Send != 1 {
|
||||
require.Failf(t, "test failed", "Attempts.Send = %d, want 1", record.Attempts.Send)
|
||||
}
|
||||
if string(record.CodeHash) == "654321" {
|
||||
require.FailNow(t, "CodeHash stored cleartext code")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSuppressesDeliveryForBlockedEmail(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
challengeStore := &testkit.InMemoryChallengeStore{}
|
||||
userDirectory := &testkit.InMemoryUserDirectory{}
|
||||
if err := userDirectory.SeedBlockedEmail(common.Email("pilot@example.com"), userresolution.BlockReasonCode("policy_block")); err != nil {
|
||||
require.Failf(t, "test failed", "SeedBlockedEmail() returned error: %v", err)
|
||||
}
|
||||
mailSender := &testkit.RecordingMailSender{}
|
||||
|
||||
service, err := New(
|
||||
challengeStore,
|
||||
userDirectory,
|
||||
&testkit.SequenceIDGenerator{ChallengeIDs: []common.ChallengeID{"challenge-1"}},
|
||||
testkit.FixedCodeGenerator{Code: "654321"},
|
||||
testkit.DeterministicCodeHasher{},
|
||||
mailSender,
|
||||
testkit.FixedClock{Time: time.Unix(10, 0).UTC()},
|
||||
)
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "New() returned error: %v", err)
|
||||
}
|
||||
|
||||
result, err := service.Execute(context.Background(), Input{Email: "pilot@example.com"})
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "Execute() returned error: %v", err)
|
||||
}
|
||||
if result.ChallengeID != "challenge-1" {
|
||||
require.Failf(t, "test failed", "Execute().ChallengeID = %q, want %q", result.ChallengeID, "challenge-1")
|
||||
}
|
||||
if len(mailSender.RecordedInputs()) != 0 {
|
||||
require.Failf(t, "test failed", "RecordedInputs() length = %d, want 0", len(mailSender.RecordedInputs()))
|
||||
}
|
||||
|
||||
record, err := 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.StatusDeliverySuppressed || record.DeliveryState != challenge.DeliverySuppressed {
|
||||
require.Failf(t, "test failed", "challenge state = %q/%q", record.Status, record.DeliveryState)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteHandlesMailSenderSuppressedOutcome(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
challengeStore := &testkit.InMemoryChallengeStore{}
|
||||
mailSender := &testkit.RecordingMailSender{
|
||||
DefaultResult: ports.SendLoginCodeResult{Outcome: ports.SendLoginCodeOutcomeSuppressed},
|
||||
}
|
||||
|
||||
service, err := New(
|
||||
challengeStore,
|
||||
&testkit.InMemoryUserDirectory{},
|
||||
&testkit.SequenceIDGenerator{ChallengeIDs: []common.ChallengeID{"challenge-1"}},
|
||||
testkit.FixedCodeGenerator{Code: "654321"},
|
||||
testkit.DeterministicCodeHasher{},
|
||||
mailSender,
|
||||
testkit.FixedClock{Time: time.Unix(10, 0).UTC()},
|
||||
)
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "New() returned error: %v", err)
|
||||
}
|
||||
|
||||
_, err = service.Execute(context.Background(), Input{Email: "pilot@example.com"})
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "Execute() returned error: %v", err)
|
||||
}
|
||||
|
||||
record, err := 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.StatusDeliverySuppressed || record.DeliveryState != challenge.DeliverySuppressed {
|
||||
require.Failf(t, "test failed", "challenge state = %q/%q", record.Status, record.DeliveryState)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteMarksChallengeFailedWhenMailSenderFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
challengeStore := &testkit.InMemoryChallengeStore{}
|
||||
mailSender := &testkit.RecordingMailSender{Err: errors.New("mail failed")}
|
||||
|
||||
service, err := New(
|
||||
challengeStore,
|
||||
&testkit.InMemoryUserDirectory{},
|
||||
&testkit.SequenceIDGenerator{ChallengeIDs: []common.ChallengeID{"challenge-1"}},
|
||||
testkit.FixedCodeGenerator{Code: "654321"},
|
||||
testkit.DeterministicCodeHasher{},
|
||||
mailSender,
|
||||
testkit.FixedClock{Time: time.Unix(10, 0).UTC()},
|
||||
)
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "New() returned error: %v", err)
|
||||
}
|
||||
|
||||
_, err = service.Execute(context.Background(), Input{Email: "pilot@example.com"})
|
||||
if shared.CodeOf(err) != shared.ErrorCodeServiceUnavailable {
|
||||
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeServiceUnavailable)
|
||||
}
|
||||
|
||||
record, err := 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 || record.DeliveryState != challenge.DeliveryFailed {
|
||||
require.Failf(t, "test failed", "challenge state = %q/%q", record.Status, record.DeliveryState)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteReturnsInvalidRequestForBadEmail(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
service, err := New(
|
||||
&testkit.InMemoryChallengeStore{},
|
||||
&testkit.InMemoryUserDirectory{},
|
||||
&testkit.SequenceIDGenerator{},
|
||||
testkit.FixedCodeGenerator{Code: "654321"},
|
||||
testkit.DeterministicCodeHasher{},
|
||||
&testkit.RecordingMailSender{},
|
||||
testkit.FixedClock{Time: time.Unix(10, 0).UTC()},
|
||||
)
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "New() returned error: %v", err)
|
||||
}
|
||||
|
||||
_, err = service.Execute(context.Background(), Input{Email: "pilot"})
|
||||
if shared.CodeOf(err) != shared.ErrorCodeInvalidRequest {
|
||||
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeInvalidRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteCreatesFreshChallengeForRepeatedSend(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
challengeStore := &testkit.InMemoryChallengeStore{}
|
||||
mailSender := &testkit.RecordingMailSender{}
|
||||
clock := testkit.FixedClock{Time: time.Unix(10, 0).UTC()}
|
||||
|
||||
service, err := New(
|
||||
challengeStore,
|
||||
&testkit.InMemoryUserDirectory{},
|
||||
&testkit.SequenceIDGenerator{
|
||||
ChallengeIDs: []common.ChallengeID{"challenge-1", "challenge-2"},
|
||||
},
|
||||
testkit.FixedCodeGenerator{Code: "654321"},
|
||||
testkit.DeterministicCodeHasher{},
|
||||
mailSender,
|
||||
clock,
|
||||
)
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "New() returned error: %v", err)
|
||||
}
|
||||
|
||||
first, err := service.Execute(context.Background(), Input{Email: "pilot@example.com"})
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "first Execute() returned error: %v", err)
|
||||
}
|
||||
second, err := service.Execute(context.Background(), Input{Email: "pilot@example.com"})
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "second Execute() returned error: %v", err)
|
||||
}
|
||||
if first.ChallengeID == second.ChallengeID {
|
||||
require.Failf(t, "test failed", "challenge ids are equal: %q", first.ChallengeID)
|
||||
}
|
||||
|
||||
firstRecord, err := challengeStore.Get(context.Background(), common.ChallengeID(first.ChallengeID))
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "Get(%q) returned error: %v", first.ChallengeID, err)
|
||||
}
|
||||
secondRecord, err := challengeStore.Get(context.Background(), common.ChallengeID(second.ChallengeID))
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "Get(%q) returned error: %v", second.ChallengeID, err)
|
||||
}
|
||||
if firstRecord.Status != challenge.StatusSent {
|
||||
require.Failf(t, "test failed", "first challenge status = %q, want %q", firstRecord.Status, challenge.StatusSent)
|
||||
}
|
||||
if secondRecord.Status != challenge.StatusSent {
|
||||
require.Failf(t, "test failed", "second challenge status = %q, want %q", secondRecord.Status, challenge.StatusSent)
|
||||
}
|
||||
if len(mailSender.RecordedInputs()) != 2 {
|
||||
require.Failf(t, "test failed", "RecordedInputs() length = %d, want 2", len(mailSender.RecordedInputs()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSetsChallengeExpirationFromInitialTTL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(10, 0).UTC()
|
||||
challengeStore := &testkit.InMemoryChallengeStore{}
|
||||
|
||||
service, err := New(
|
||||
challengeStore,
|
||||
&testkit.InMemoryUserDirectory{},
|
||||
&testkit.SequenceIDGenerator{ChallengeIDs: []common.ChallengeID{"challenge-1"}},
|
||||
testkit.FixedCodeGenerator{Code: "654321"},
|
||||
testkit.DeterministicCodeHasher{},
|
||||
&testkit.RecordingMailSender{},
|
||||
testkit.FixedClock{Time: now},
|
||||
)
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "New() returned error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := service.Execute(context.Background(), Input{Email: "pilot@example.com"}); err != nil {
|
||||
require.Failf(t, "test failed", "Execute() returned error: %v", err)
|
||||
}
|
||||
|
||||
record, err := challengeStore.Get(context.Background(), common.ChallengeID("challenge-1"))
|
||||
if err != nil {
|
||||
require.Failf(t, "test failed", "Get() returned error: %v", err)
|
||||
}
|
||||
wantExpiresAt := now.Add(challenge.InitialTTL)
|
||||
if !record.ExpiresAt.Equal(wantExpiresAt) {
|
||||
require.Failf(t, "test failed", "ExpiresAt = %s, want %s", record.ExpiresAt, wantExpiresAt)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package sendemailcode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stubmail "galaxy/authsession/internal/adapters/mail"
|
||||
"galaxy/authsession/internal/domain/challenge"
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
"galaxy/authsession/internal/service/shared"
|
||||
"galaxy/authsession/internal/testkit"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestExecuteWithStubSender(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sender *stubmail.StubSender
|
||||
wantStatus challenge.Status
|
||||
wantDeliveryState challenge.DeliveryState
|
||||
wantErrorCode string
|
||||
wantRecordedAttempt int
|
||||
}{
|
||||
{
|
||||
name: "sent",
|
||||
sender: &stubmail.StubSender{},
|
||||
wantStatus: challenge.StatusSent,
|
||||
wantDeliveryState: challenge.DeliverySent,
|
||||
wantRecordedAttempt: 1,
|
||||
},
|
||||
{
|
||||
name: "suppressed",
|
||||
sender: &stubmail.StubSender{
|
||||
DefaultMode: stubmail.StubModeSuppressed,
|
||||
},
|
||||
wantStatus: challenge.StatusDeliverySuppressed,
|
||||
wantDeliveryState: challenge.DeliverySuppressed,
|
||||
wantRecordedAttempt: 1,
|
||||
},
|
||||
{
|
||||
name: "failed",
|
||||
sender: &stubmail.StubSender{
|
||||
DefaultMode: stubmail.StubModeFailed,
|
||||
DefaultError: errors.New("stub delivery failed"),
|
||||
},
|
||||
wantStatus: challenge.StatusFailed,
|
||||
wantDeliveryState: challenge.DeliveryFailed,
|
||||
wantErrorCode: shared.ErrorCodeServiceUnavailable,
|
||||
wantRecordedAttempt: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
challengeStore := &testkit.InMemoryChallengeStore{}
|
||||
service, err := New(
|
||||
challengeStore,
|
||||
&testkit.InMemoryUserDirectory{},
|
||||
&testkit.SequenceIDGenerator{ChallengeIDs: []common.ChallengeID{"challenge-1"}},
|
||||
testkit.FixedCodeGenerator{Code: "654321"},
|
||||
testkit.DeterministicCodeHasher{},
|
||||
tt.sender,
|
||||
testkit.FixedClock{Time: time.Unix(10, 0).UTC()},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), Input{Email: "pilot@example.com"})
|
||||
if tt.wantErrorCode == "" {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "challenge-1", result.ChallengeID)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, tt.wantErrorCode, shared.CodeOf(err))
|
||||
assert.Equal(t, Result{}, result)
|
||||
}
|
||||
|
||||
record, getErr := challengeStore.Get(context.Background(), common.ChallengeID("challenge-1"))
|
||||
require.NoError(t, getErr)
|
||||
assert.Equal(t, tt.wantStatus, record.Status)
|
||||
assert.Equal(t, tt.wantDeliveryState, record.DeliveryState)
|
||||
|
||||
attempts := tt.sender.RecordedAttempts()
|
||||
require.Len(t, attempts, tt.wantRecordedAttempt)
|
||||
assert.Equal(t, common.Email("pilot@example.com"), attempts[0].Input.Email)
|
||||
assert.Equal(t, "654321", attempts[0].Input.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package sendemailcode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stubmail "galaxy/authsession/internal/adapters/mail"
|
||||
stubuserservice "galaxy/authsession/internal/adapters/userservice"
|
||||
"galaxy/authsession/internal/domain/challenge"
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
"galaxy/authsession/internal/domain/userresolution"
|
||||
"galaxy/authsession/internal/testkit"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestExecuteWithRuntimeStubUserDirectory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
seed func(*stubuserservice.StubDirectory) error
|
||||
email string
|
||||
wantStatus challenge.Status
|
||||
wantDeliveryState challenge.DeliveryState
|
||||
wantMailCalls int
|
||||
}{
|
||||
{
|
||||
name: "existing user",
|
||||
email: "pilot@example.com",
|
||||
seed: func(directory *stubuserservice.StubDirectory) error {
|
||||
return directory.SeedExisting(common.Email("pilot@example.com"), common.UserID("user-1"))
|
||||
},
|
||||
wantStatus: challenge.StatusSent,
|
||||
wantDeliveryState: challenge.DeliverySent,
|
||||
wantMailCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "creatable user",
|
||||
email: "new@example.com",
|
||||
seed: func(*stubuserservice.StubDirectory) error { return nil },
|
||||
wantStatus: challenge.StatusSent,
|
||||
wantDeliveryState: challenge.DeliverySent,
|
||||
wantMailCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "blocked email",
|
||||
email: "blocked@example.com",
|
||||
seed: func(directory *stubuserservice.StubDirectory) error {
|
||||
return directory.SeedBlockedEmail(common.Email("blocked@example.com"), userresolution.BlockReasonCode("policy_block"))
|
||||
},
|
||||
wantStatus: challenge.StatusDeliverySuppressed,
|
||||
wantDeliveryState: challenge.DeliverySuppressed,
|
||||
wantMailCalls: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
userDirectory := &stubuserservice.StubDirectory{}
|
||||
require.NoError(t, tt.seed(userDirectory))
|
||||
|
||||
challengeStore := &testkit.InMemoryChallengeStore{}
|
||||
mailSender := &stubmail.StubSender{}
|
||||
service, err := New(
|
||||
challengeStore,
|
||||
userDirectory,
|
||||
&testkit.SequenceIDGenerator{ChallengeIDs: []common.ChallengeID{"challenge-1"}},
|
||||
testkit.FixedCodeGenerator{Code: "654321"},
|
||||
testkit.DeterministicCodeHasher{},
|
||||
mailSender,
|
||||
testkit.FixedClock{Time: time.Unix(10, 0).UTC()},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), Input{Email: tt.email})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "challenge-1", result.ChallengeID)
|
||||
|
||||
record, err := challengeStore.Get(context.Background(), common.ChallengeID("challenge-1"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantStatus, record.Status)
|
||||
assert.Equal(t, tt.wantDeliveryState, record.DeliveryState)
|
||||
assert.Len(t, mailSender.RecordedAttempts(), tt.wantMailCalls)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package sendemailcode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
"galaxy/authsession/internal/domain/userresolution"
|
||||
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 TestExecuteRecordsSentMetric(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, reader := newObservedTelemetryRuntime(t)
|
||||
service, _, mailSender := newObservedSendService(t, observedSendOptions{
|
||||
Telemetry: runtime,
|
||||
})
|
||||
|
||||
_, err := service.Execute(context.Background(), Input{Email: "pilot@example.com"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, mailSender.RecordedInputs(), 1)
|
||||
|
||||
assertMetricCount(t, reader, "authsession.send_email_code.attempts", map[string]string{
|
||||
"outcome": "sent",
|
||||
}, 1)
|
||||
}
|
||||
|
||||
func TestExecuteRecordsBlockedSuppressedMetric(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, reader := newObservedTelemetryRuntime(t)
|
||||
service, _, _ := newObservedSendService(t, observedSendOptions{
|
||||
Telemetry: runtime,
|
||||
SeedBlockedEmail: true,
|
||||
})
|
||||
|
||||
_, err := service.Execute(context.Background(), Input{Email: "pilot@example.com"})
|
||||
require.NoError(t, err)
|
||||
|
||||
assertMetricCount(t, reader, "authsession.send_email_code.attempts", map[string]string{
|
||||
"outcome": "suppressed",
|
||||
"reason": "blocked",
|
||||
}, 1)
|
||||
}
|
||||
|
||||
func TestExecuteRecordsThrottledMetric(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, reader := newObservedTelemetryRuntime(t)
|
||||
abuseProtector := &testkit.InMemorySendEmailCodeAbuseProtector{}
|
||||
now := time.Unix(10, 0).UTC()
|
||||
require.NoError(t, reserveSendCooldown(abuseProtector, common.Email("pilot@example.com"), now))
|
||||
|
||||
service, _, mailSender := newObservedSendService(t, observedSendOptions{
|
||||
Telemetry: runtime,
|
||||
AbuseProtector: abuseProtector,
|
||||
Clock: testkit.FixedClock{Time: now},
|
||||
})
|
||||
|
||||
_, err := service.Execute(context.Background(), Input{Email: "pilot@example.com"})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, mailSender.RecordedInputs())
|
||||
|
||||
assertMetricCount(t, reader, "authsession.send_email_code.attempts", map[string]string{
|
||||
"outcome": "throttled",
|
||||
"reason": "throttled",
|
||||
}, 1)
|
||||
}
|
||||
|
||||
type observedSendOptions struct {
|
||||
Telemetry *authtelemetry.Runtime
|
||||
AbuseProtector *testkit.InMemorySendEmailCodeAbuseProtector
|
||||
SeedBlockedEmail bool
|
||||
Clock portsClock
|
||||
}
|
||||
|
||||
type portsClock interface {
|
||||
Now() time.Time
|
||||
}
|
||||
|
||||
func newObservedSendService(t *testing.T, options observedSendOptions) (*Service, *testkit.InMemoryChallengeStore, *testkit.RecordingMailSender) {
|
||||
t.Helper()
|
||||
|
||||
challengeStore := &testkit.InMemoryChallengeStore{}
|
||||
userDirectory := &testkit.InMemoryUserDirectory{}
|
||||
if options.SeedBlockedEmail {
|
||||
require.NoError(t, userDirectory.SeedBlockedEmail(common.Email("pilot@example.com"), userresolution.BlockReasonCode("policy_block")))
|
||||
}
|
||||
mailSender := &testkit.RecordingMailSender{}
|
||||
clock := options.Clock
|
||||
if clock == nil {
|
||||
clock = testkit.FixedClock{Time: time.Unix(10, 0).UTC()}
|
||||
}
|
||||
|
||||
service, err := NewWithRuntime(
|
||||
challengeStore,
|
||||
userDirectory,
|
||||
&testkit.SequenceIDGenerator{ChallengeIDs: []common.ChallengeID{"challenge-1"}},
|
||||
testkit.FixedCodeGenerator{Code: "654321"},
|
||||
testkit.DeterministicCodeHasher{},
|
||||
mailSender,
|
||||
options.AbuseProtector,
|
||||
clock,
|
||||
options.Telemetry,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
return service, challengeStore, mailSender
|
||||
}
|
||||
|
||||
func newObservedTelemetryRuntime(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 assertMetricCount(t *testing.T, reader *sdkmetric.ManualReader, metricName string, 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 != metricName {
|
||||
continue
|
||||
}
|
||||
|
||||
sum, ok := metric.Data.(metricdata.Sum[int64])
|
||||
require.True(t, ok)
|
||||
|
||||
for _, point := range sum.DataPoints {
|
||||
if hasMetricAttributes(point.Attributes.ToSlice(), wantAttrs) {
|
||||
assert.Equal(t, wantValue, point.Value)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
require.Failf(t, "test failed", "metric %q with attrs %v not found", metricName, wantAttrs)
|
||||
}
|
||||
|
||||
func hasMetricAttributes(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