feat: authsession service

This commit is contained in:
Ilia Denisov
2026-04-08 16:23:07 +02:00
committed by GitHub
parent 28f04916af
commit 86a68ed9d0
174 changed files with 31732 additions and 112 deletions
@@ -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
}