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())) } if mailSender.RecordedInputs()[0].Locale != "en" { require.Failf(t, "test failed", "mail locale = %q, want %q", mailSender.RecordedInputs()[0].Locale, "en") } 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 record.PreferredLanguage != "en" { require.Failf(t, "test failed", "PreferredLanguage = %q, want %q", record.PreferredLanguage, "en") } 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) } if record.PreferredLanguage != "en" { require.Failf(t, "test failed", "PreferredLanguage = %q, want %q", record.PreferredLanguage, "en") } } 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) } if record.PreferredLanguage != "en" { require.Failf(t, "test failed", "PreferredLanguage = %q, want %q", record.PreferredLanguage, "en") } } 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) } if record.PreferredLanguage != "en" { require.Failf(t, "test failed", "PreferredLanguage = %q, want %q", record.PreferredLanguage, "en") } } 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) } } func TestExecuteResolvesPreferredLanguageFromAcceptLanguage(t *testing.T) { t.Parallel() tests := []struct { name string acceptLanguage string wantPreferredLang string }{ { name: "canonical valid tag wins", acceptLanguage: "fr-FR, en;q=0.8", wantPreferredLang: "fr-FR", }, { name: "wildcard falls back to english", acceptLanguage: "*", wantPreferredLang: "en", }, { name: "malformed header falls back to english", acceptLanguage: "fr-FR, @@", wantPreferredLang: "en", }, { name: "missing header falls back to english", acceptLanguage: "", wantPreferredLang: "en", }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() challengeStore := &testkit.InMemoryChallengeStore{} mailSender := &testkit.RecordingMailSender{} 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()}, ) require.NoError(t, err) _, err = service.Execute(context.Background(), Input{ Email: "pilot@example.com", AcceptLanguage: tt.acceptLanguage, }) require.NoError(t, err) record, err := challengeStore.Get(context.Background(), common.ChallengeID("challenge-1")) require.NoError(t, err) require.Equal(t, tt.wantPreferredLang, record.PreferredLanguage) attempts := mailSender.RecordedInputs() require.Len(t, attempts, 1) require.Equal(t, tt.wantPreferredLang, attempts[0].Locale) }) } }